HouYunFei Codex commited on
Commit
bef1a50
·
1 Parent(s): 85b5785

增加图片管理、日志管理,优化敏感词等场景

Browse files

Co-Authored-By: Codex <noreply@openai.com>

VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
 
1
+ 1.0.0
api/ai.py CHANGED
@@ -1,21 +1,19 @@
1
  from __future__ import annotations
2
 
3
- from datetime import datetime
4
- import time
5
-
6
  from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
7
  from fastapi.concurrency import run_in_threadpool
8
- from fastapi.responses import StreamingResponse
9
  from pydantic import BaseModel, ConfigDict, Field
10
 
11
- from api.support import raise_image_quota_error, require_identity, resolve_image_base_url
12
- from services.account_service import account_service
13
- from services.chatgpt_service import ChatGPTService, ImageGenerationError
14
- from services.log_service import (
15
- LOG_TYPE_CALL,
16
- log_service,
 
 
 
17
  )
18
- from utils.helper import anthropic_sse_stream, is_image_chat_request, sse_json_stream
19
 
20
 
21
  class ImageGenerationRequest(BaseModel):
@@ -55,66 +53,14 @@ class AnthropicMessageRequest(BaseModel):
55
  stream: bool | None = None
56
 
57
 
58
- def _identity_detail(identity: dict[str, object]) -> dict[str, object]:
59
- return {"key_id": identity.get("id"), "key_name": identity.get("name"), "role": identity.get("role")}
60
-
61
-
62
- def _collect_urls(value: object) -> list[str]:
63
- urls: list[str] = []
64
- if isinstance(value, dict):
65
- for key, item in value.items():
66
- if key == "url" and isinstance(item, str):
67
- urls.append(item)
68
- elif key == "urls" and isinstance(item, list):
69
- urls.extend(str(url) for url in item if isinstance(url, str))
70
- else:
71
- urls.extend(_collect_urls(item))
72
- elif isinstance(value, list):
73
- for item in value:
74
- urls.extend(_collect_urls(item))
75
- return urls
76
-
77
-
78
- def _log_call(summary: str, identity: dict[str, object], endpoint: str, model: str, started: float, result: object = None, status: str = "success") -> None:
79
- detail = {
80
- **_identity_detail(identity),
81
- "endpoint": endpoint,
82
- "model": model,
83
- "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
84
- "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
85
- "duration_ms": int((time.time() - started) * 1000),
86
- "status": status,
87
- }
88
- urls = _collect_urls(result)
89
- if urls:
90
- detail["urls"] = urls
91
- log_service.add(LOG_TYPE_CALL, summary, detail)
92
-
93
-
94
- def _stream_with_log(items, summary: str, identity: dict[str, object], endpoint: str, model: str, started: float):
95
- urls: list[str] = []
96
- failed = False
97
- try:
98
- for item in items:
99
- urls.extend(_collect_urls(item))
100
- yield item
101
- except Exception:
102
- failed = True
103
- _log_call(summary.replace("结束", "失败"), identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))}, "failed")
104
- raise
105
- finally:
106
- if not failed:
107
- _log_call(summary, identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))})
108
-
109
-
110
- def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
111
  router = APIRouter()
112
 
113
  @router.get("/v1/models")
114
  async def list_models(authorization: str | None = Header(default=None)):
115
  require_identity(authorization)
116
  try:
117
- return await run_in_threadpool(chatgpt_service.list_models)
118
  except Exception as exc:
119
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
120
 
@@ -125,37 +71,10 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
125
  authorization: str | None = Header(default=None),
126
  ):
127
  identity = require_identity(authorization)
128
- started = time.time()
129
- base_url = resolve_image_base_url(request)
130
- if body.stream:
131
- try:
132
- await run_in_threadpool(account_service.get_available_access_token)
133
- except RuntimeError as exc:
134
- raise_image_quota_error(exc)
135
- return StreamingResponse(
136
- sse_json_stream(
137
- _stream_with_log(
138
- chatgpt_service.stream_image_generation(
139
- body.prompt, body.model, body.n, body.size, body.response_format, base_url
140
- ),
141
- "文生图流式调用结束",
142
- identity,
143
- "/v1/images/generations",
144
- body.model,
145
- started,
146
- )
147
- ),
148
- media_type="text/event-stream",
149
- )
150
- try:
151
- result = await run_in_threadpool(
152
- chatgpt_service.generate_with_pool, body.prompt, body.model, body.n, body.size, body.response_format, base_url
153
- )
154
- _log_call("文生图调用完成", identity, "/v1/images/generations", body.model, started, result)
155
- return result
156
- except ImageGenerationError as exc:
157
- _log_call("文生图调用失败", identity, "/v1/images/generations", body.model, started, {"error": str(exc)}, "failed")
158
- raise_image_quota_error(exc)
159
 
160
  @router.post("/v1/images/edits")
161
  async def edit_images(
@@ -171,85 +90,45 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
171
  stream: bool | None = Form(default=None),
172
  ):
173
  identity = require_identity(authorization)
174
- started = time.time()
175
  if n < 1 or n > 4:
176
  raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
177
  uploads = [*(image or []), *(image_list or [])]
178
  if not uploads:
179
  raise HTTPException(status_code=400, detail={"error": "image file is required"})
180
- base_url = resolve_image_base_url(request)
181
  images: list[tuple[bytes, str, str]] = []
182
  for upload in uploads:
183
  image_data = await upload.read()
184
  if not image_data:
185
  raise HTTPException(status_code=400, detail={"error": "image file is empty"})
186
  images.append((image_data, upload.filename or "image.png", upload.content_type or "image/png"))
187
- if stream:
188
- if not account_service.has_available_account():
189
- raise_image_quota_error(RuntimeError("no available image quota"))
190
- return StreamingResponse(
191
- sse_json_stream(_stream_with_log(
192
- chatgpt_service.stream_image_edit(prompt, images, model, n, size, response_format, base_url),
193
- "图生图流式调用结束",
194
- identity,
195
- "/v1/images/edits",
196
- model,
197
- started,
198
- )),
199
- media_type="text/event-stream",
200
- )
201
- try:
202
- result = await run_in_threadpool(
203
- chatgpt_service.edit_with_pool, prompt, images, model, n, size, response_format, base_url
204
- )
205
- _log_call("图生图调用完成", identity, "/v1/images/edits", model, started, result)
206
- return result
207
- except ImageGenerationError as exc:
208
- _log_call("图生图调用失败", identity, "/v1/images/edits", model, started, {"error": str(exc)}, "failed")
209
- raise_image_quota_error(exc)
210
 
211
  @router.post("/v1/chat/completions")
212
  async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
213
  identity = require_identity(authorization)
214
- started = time.time()
215
  payload = body.model_dump(mode="python")
216
  model = str(payload.get("model") or "auto")
217
- if bool(payload.get("stream")):
218
- if is_image_chat_request(payload):
219
- try:
220
- await run_in_threadpool(account_service.get_available_access_token)
221
- except RuntimeError as exc:
222
- raise_image_quota_error(exc)
223
- return StreamingResponse(
224
- sse_json_stream(_stream_with_log(chatgpt_service.stream_chat_completion(payload), "文本生成流式调用结束", identity, "/v1/chat/completions", model, started)),
225
- media_type="text/event-stream",
226
- )
227
- try:
228
- result = await run_in_threadpool(chatgpt_service.create_chat_completion, payload)
229
- _log_call("文本生成调用完成", identity, "/v1/chat/completions", model, started, result)
230
- return result
231
- except Exception as exc:
232
- _log_call("文本生成调用失败", identity, "/v1/chat/completions", model, started, {"error": str(exc)}, "failed")
233
- raise
234
 
235
  @router.post("/v1/responses")
236
  async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
237
  identity = require_identity(authorization)
238
- started = time.time()
239
  payload = body.model_dump(mode="python")
240
  model = str(payload.get("model") or "auto")
241
- if bool(payload.get("stream")):
242
- return StreamingResponse(
243
- sse_json_stream(_stream_with_log(chatgpt_service.stream_response(payload), "Responses 流式调用结束", identity, "/v1/responses", model, started)),
244
- media_type="text/event-stream",
245
- )
246
- try:
247
- result = await run_in_threadpool(chatgpt_service.create_response, payload)
248
- _log_call("Responses 调用完成", identity, "/v1/responses", model, started, result)
249
- return result
250
- except Exception as exc:
251
- _log_call("Responses 调用失败", identity, "/v1/responses", model, started, {"error": str(exc)}, "failed")
252
- raise
253
 
254
  @router.post("/v1/messages")
255
  async def create_message(
@@ -259,20 +138,9 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
259
  anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
260
  ):
261
  identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
262
- started = time.time()
263
  payload = body.model_dump(mode="python")
264
  model = str(payload.get("model") or "auto")
265
- if bool(payload.get("stream")):
266
- return StreamingResponse(
267
- anthropic_sse_stream(_stream_with_log(chatgpt_service.stream_message(payload), "Messages 流式调用结束", identity, "/v1/messages", model, started)),
268
- media_type="text/event-stream",
269
- )
270
- try:
271
- result = await run_in_threadpool(chatgpt_service.create_message, payload)
272
- _log_call("Messages 调用完成", identity, "/v1/messages", model, started, result)
273
- return result
274
- except Exception as exc:
275
- _log_call("Messages 调用失败", identity, "/v1/messages", model, started, {"error": str(exc)}, "failed")
276
- raise
277
 
278
  return router
 
1
  from __future__ import annotations
2
 
 
 
 
3
  from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
4
  from fastapi.concurrency import run_in_threadpool
 
5
  from pydantic import BaseModel, ConfigDict, Field
6
 
7
+ from api.support import require_identity, resolve_image_base_url
8
+ from services.log_service import LoggedCall
9
+ from services.protocol import (
10
+ anthropic_v1_messages,
11
+ openai_v1_chat_complete,
12
+ openai_v1_image_edit,
13
+ openai_v1_image_generations,
14
+ openai_v1_models,
15
+ openai_v1_response,
16
  )
 
17
 
18
 
19
  class ImageGenerationRequest(BaseModel):
 
53
  stream: bool | None = None
54
 
55
 
56
+ def create_router() -> APIRouter:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  router = APIRouter()
58
 
59
  @router.get("/v1/models")
60
  async def list_models(authorization: str | None = Header(default=None)):
61
  require_identity(authorization)
62
  try:
63
+ return await run_in_threadpool(openai_v1_models.list_models)
64
  except Exception as exc:
65
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
66
 
 
71
  authorization: str | None = Header(default=None),
72
  ):
73
  identity = require_identity(authorization)
74
+ payload = body.model_dump(mode="python")
75
+ payload["base_url"] = resolve_image_base_url(request)
76
+ call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图")
77
+ return await call.run(openai_v1_image_generations.handle, payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  @router.post("/v1/images/edits")
80
  async def edit_images(
 
90
  stream: bool | None = Form(default=None),
91
  ):
92
  identity = require_identity(authorization)
 
93
  if n < 1 or n > 4:
94
  raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
95
  uploads = [*(image or []), *(image_list or [])]
96
  if not uploads:
97
  raise HTTPException(status_code=400, detail={"error": "image file is required"})
 
98
  images: list[tuple[bytes, str, str]] = []
99
  for upload in uploads:
100
  image_data = await upload.read()
101
  if not image_data:
102
  raise HTTPException(status_code=400, detail={"error": "image file is empty"})
103
  images.append((image_data, upload.filename or "image.png", upload.content_type or "image/png"))
104
+ payload = {
105
+ "prompt": prompt,
106
+ "images": images,
107
+ "model": model,
108
+ "n": n,
109
+ "size": size,
110
+ "response_format": response_format,
111
+ "stream": stream,
112
+ "base_url": resolve_image_base_url(request),
113
+ }
114
+ call = LoggedCall(identity, "/v1/images/edits", model, "图生图")
115
+ return await call.run(openai_v1_image_edit.handle, payload)
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  @router.post("/v1/chat/completions")
118
  async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
119
  identity = require_identity(authorization)
 
120
  payload = body.model_dump(mode="python")
121
  model = str(payload.get("model") or "auto")
122
+ call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成")
123
+ return await call.run(openai_v1_chat_complete.handle, payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  @router.post("/v1/responses")
126
  async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
127
  identity = require_identity(authorization)
 
128
  payload = body.model_dump(mode="python")
129
  model = str(payload.get("model") or "auto")
130
+ call = LoggedCall(identity, "/v1/responses", model, "Responses")
131
+ return await call.run(openai_v1_response.handle, payload)
 
 
 
 
 
 
 
 
 
 
132
 
133
  @router.post("/v1/messages")
134
  async def create_message(
 
138
  anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
139
  ):
140
  identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
 
141
  payload = body.model_dump(mode="python")
142
  model = str(payload.get("model") or "auto")
143
+ call = LoggedCall(identity, "/v1/messages", model, "Messages")
144
+ return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
 
 
 
 
 
 
 
 
 
 
145
 
146
  return router
api/app.py CHANGED
@@ -10,13 +10,10 @@ from fastapi.staticfiles import StaticFiles
10
 
11
  from api import accounts, ai, system
12
  from api.support import resolve_web_asset, start_limited_account_watcher
13
- from services.account_service import account_service
14
- from services.chatgpt_service import ChatGPTService
15
  from services.config import config
16
 
17
 
18
  def create_app() -> FastAPI:
19
- chatgpt_service = ChatGPTService(account_service)
20
  app_version = config.app_version
21
 
22
  @asynccontextmanager
@@ -38,7 +35,7 @@ def create_app() -> FastAPI:
38
  allow_methods=["*"],
39
  allow_headers=["*"],
40
  )
41
- app.include_router(ai.create_router(chatgpt_service))
42
  app.include_router(accounts.create_router())
43
  app.include_router(system.create_router(app_version))
44
  if config.images_dir.exists():
 
10
 
11
  from api import accounts, ai, system
12
  from api.support import resolve_web_asset, start_limited_account_watcher
 
 
13
  from services.config import config
14
 
15
 
16
  def create_app() -> FastAPI:
 
17
  app_version = config.app_version
18
 
19
  @asynccontextmanager
 
35
  allow_methods=["*"],
36
  allow_headers=["*"],
37
  )
38
+ app.include_router(ai.create_router())
39
  app.include_router(accounts.create_router())
40
  app.include_router(system.create_router(app_version))
41
  if config.images_dir.exists():
config.json CHANGED
@@ -3,6 +3,12 @@
3
  "refresh_account_interval_minute": 60,
4
  "image_retention_days": 15,
5
  "auto_remove_invalid_accounts": false,
 
 
 
 
 
 
6
  "proxy": "",
7
  "base_url": ""
8
  }
 
3
  "refresh_account_interval_minute": 60,
4
  "image_retention_days": 15,
5
  "auto_remove_invalid_accounts": false,
6
+ "log_levels": [
7
+ "debug",
8
+ "error",
9
+ "info",
10
+ "warning"
11
+ ],
12
  "proxy": "",
13
  "base_url": ""
14
  }
docs/upstream-sse-conversation.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 上游 Conversation SSE 协议说明
2
+
3
+ Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。
4
+
5
+ ## 基本形态
6
+
7
+ 常见 payload 示例:
8
+
9
+ ```text
10
+ "v1"
11
+ {"type":"resume_conversation_token",...}
12
+ {"p":"","o":"add","v":{...}}
13
+ {"v":{...}}
14
+ {"p":"/message/content/parts/0","o":"append","v":"..."}
15
+ {"type":"server_ste_metadata","metadata":{...}}
16
+ [DONE]
17
+ ```
18
+
19
+ 处理建议:
20
+
21
+ | payload | 含义 | 处理方式 |
22
+ |:--|:--|:--|
23
+ | `"v1"` | 协议版本标记 | 可记录,通常不影响业务 |
24
+ | `[DONE]` | 当前 SSE 流结束 | 停止继续读取 |
25
+ | JSON object | 事件、消息或 patch | 按字段更新会话状态 |
26
+ | JSON string | 短文本 patch 或协议标记 | 结合上下文处理 |
27
+ | 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 |
28
+
29
+ ## 常用字段
30
+
31
+ | 字段 | 说明 |
32
+ |:--|:--|
33
+ | `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` |
34
+ | `conversation_id` | 当前会话 ID,可从多个事件中获得 |
35
+ | `p` | patch 路径,例如 `/message/content/parts/0` |
36
+ | `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` |
37
+ | `v` | patch 值,可能是字符串、数组,也可能包含完整 message |
38
+ | `c` | 消息序号或游标,常见于 add 类事件 |
39
+ | `message.id` | 消息 ID |
40
+ | `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` |
41
+ | `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` |
42
+ | `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 |
43
+ | `message.status` | 消息状态,如 `in_progress`、`finished_successfully` |
44
+ | `message.end_turn` | 是否结束当前轮次 |
45
+ | `metadata.tool_invoked` | 本轮是否调用工具 |
46
+ | `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` |
47
+ | `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` |
48
+
49
+ ## 会话启动事件
50
+
51
+ 上游通常会先返回恢复令牌或会话令牌:
52
+
53
+ ```json
54
+ {
55
+ "type": "resume_conversation_token",
56
+ "kind": "topic",
57
+ "token": "...",
58
+ "conversation_id": "..."
59
+ }
60
+ ```
61
+
62
+ 这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。
63
+
64
+ ## 消息 add 场景
65
+
66
+ 完整消息可能通过 `add` 或带 `v.message` 的事件出现:
67
+
68
+ ```json
69
+ {
70
+ "p": "",
71
+ "o": "add",
72
+ "v": {
73
+ "message": {
74
+ "author": {"role": "assistant"},
75
+ "content": {"content_type": "text", "parts": [""]},
76
+ "status": "in_progress"
77
+ },
78
+ "conversation_id": "..."
79
+ },
80
+ "c": 3
81
+ }
82
+ ```
83
+
84
+ 此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。
85
+
86
+ ## 文本增量场景
87
+
88
+ 文本输出通常由多条 patch 组成:
89
+
90
+ ```json
91
+ {"p":"/message/content/parts/0","o":"append","v":"Hello"}
92
+ {"v":" world"}
93
+ {"p":"","o":"patch","v":[
94
+ {"p":"/message/content/parts/0","o":"append","v":"!"},
95
+ {"p":"/message/status","o":"replace","v":"finished_successfully"},
96
+ {"p":"/message/end_turn","o":"replace","v":true}
97
+ ]}
98
+ ```
99
+
100
+ 处理要点:
101
+
102
+ | 形态 | 含义 |
103
+ |:--|:--|
104
+ | `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 |
105
+ | `o == "replace"` | 用新值替换目标字段 |
106
+ | `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 |
107
+ | 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 |
108
+
109
+ ## 输入消息场景
110
+
111
+ 用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图:
112
+
113
+ ```json
114
+ {
115
+ "type": "input_message",
116
+ "input_message": {
117
+ "author": {"role": "user"},
118
+ "content": {
119
+ "content_type": "multimodal_text",
120
+ "parts": [
121
+ {"asset_pointer": "sediment://file_input"},
122
+ "编辑提示词"
123
+ ]
124
+ }
125
+ },
126
+ "conversation_id": "..."
127
+ }
128
+ ```
129
+
130
+ 这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。
131
+
132
+ ## 图片工具成功场景
133
+
134
+ 图片生成或图片编辑成功时,上游一般会出现工具消息:
135
+
136
+ ```json
137
+ {
138
+ "v": {
139
+ "message": {
140
+ "author": {"role": "tool"},
141
+ "content": {
142
+ "content_type": "multimodal_text",
143
+ "parts": [
144
+ {"asset_pointer": "file-service://file_result"},
145
+ {"asset_pointer": "sediment://file_result"}
146
+ ]
147
+ },
148
+ "metadata": {"async_task_type": "image_gen"}
149
+ }
150
+ },
151
+ "conversation_id": "..."
152
+ }
153
+ ```
154
+
155
+ 只有同时满足以下条件的图片指针,才应该视为输出结果:
156
+
157
+ | 条件 | 说明 |
158
+ |:--|:--|
159
+ | `message.author.role == "tool"` | 来源是工具消息 |
160
+ | `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 |
161
+ | `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 |
162
+
163
+ ## 图片指针类型
164
+
165
+ | 指针 | 常见来源 | 说明 |
166
+ |:--|:--|:--|
167
+ | `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 |
168
+ | `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 |
169
+ | `file_upload` | 上传过程占位 | 通常不应作为输出 |
170
+
171
+ 不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。
172
+
173
+ ## 策略拒绝场景
174
+
175
+ 当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本:
176
+
177
+ ```text
178
+ I can't assist with that request. If you have another type of modification...
179
+ ```
180
+
181
+ 常见伴随事件:
182
+
183
+ ```json
184
+ {"type":"title_generation","title":"Request Denied","conversation_id":"..."}
185
+ ```
186
+
187
+ ```json
188
+ {
189
+ "type": "server_ste_metadata",
190
+ "metadata": {
191
+ "tool_invoked": false,
192
+ "turn_use_case": "multimodal",
193
+ "did_prompt_contain_image": true
194
+ },
195
+ "conversation_id": "..."
196
+ }
197
+ ```
198
+
199
+ 处理要点:
200
+
201
+ | 条件 | 行为 |
202
+ |:--|:--|
203
+ | 有 assistant 拒绝文本 | 应返回文本消息 |
204
+ | `tool_invoked == false` | 说明没有实际工具结果 |
205
+ | 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 |
206
+ | 用户输入消息里有图片指针 | 仍然只视为输入附件 |
207
+
208
+ ## moderation 场景
209
+
210
+ 部分请求可能返回 moderation 事件:
211
+
212
+ ```json
213
+ {
214
+ "type": "moderation",
215
+ "moderation_response": {
216
+ "blocked": true
217
+ },
218
+ "conversation_id": "..."
219
+ }
220
+ ```
221
+
222
+ 若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。
223
+
224
+ ## marker 和 title 事件
225
+
226
+ 上游会返回一些辅助事件:
227
+
228
+ ```json
229
+ {"type":"message_marker","marker":"user_visible_token","event":"first"}
230
+ {"type":"message_marker","marker":"last_token","event":"last"}
231
+ {"type":"title_generation","title":"...","conversation_id":"..."}
232
+ ```
233
+
234
+ 这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。
235
+
236
+ ## metadata 事件
237
+
238
+ `server_ste_metadata` 用于描述本轮调度和工具状态:
239
+
240
+ ```json
241
+ {
242
+ "type": "server_ste_metadata",
243
+ "metadata": {
244
+ "tool_invoked": true,
245
+ "turn_use_case": "multimodal",
246
+ "model_slug": "i-mini-m",
247
+ "did_prompt_contain_image": true
248
+ }
249
+ }
250
+ ```
251
+
252
+ 常用判断:
253
+
254
+ | 字段 | 说明 |
255
+ |:--|:--|
256
+ | `tool_invoked == true` | 上游认为本轮调用过工具 |
257
+ | `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 |
258
+ | `turn_use_case == "text"` | 按文本响应处理 |
259
+ | `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 |
260
+ | `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 |
261
+
262
+ ## 结束后的结果判断
263
+
264
+ SSE 结束后可按以下顺序判断结果:
265
+
266
+ 1. 如果已经收集到图片工具输出指针,解析并下载输出图片。
267
+ 2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。
268
+ 3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。
269
+ 4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。
270
+ 5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。
271
+
services/account_service.py CHANGED
@@ -4,7 +4,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
4
  import base64
5
  import hashlib
6
  import json
7
- from pathlib import Path
8
  from threading import Lock
9
  from typing import Any
10
  from datetime import datetime
@@ -252,8 +251,7 @@ class AccountService:
252
  message = str(exc)
253
  print(f"[account-available] refresh token={token_ref} fail {message}")
254
  if "/backend-api/me failed: HTTP 401" in message:
255
- if config.auto_remove_invalid_accounts and self.remove_token(access_token):
256
- log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_account_state", "token": token_ref})
257
  return None
258
  return self.update_account(
259
  access_token,
@@ -280,6 +278,22 @@ class AccountService:
280
  f"status={account.get('status') if account else 'unknown'}"
281
  )
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  def next_token(self) -> str:
284
  return self.get_available_access_token()
285
 
@@ -505,9 +519,7 @@ class AccountService:
505
  message = str(exc)
506
  print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
507
  if "/backend-api/me failed: HTTP 401" in message:
508
- if config.auto_remove_invalid_accounts and self.remove_token(access_token):
509
- log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_accounts", "token": anonymize_token(access_token)})
510
- else:
511
  self.update_account(access_token, {"status": "异常", "quota": 0})
512
  message = "检测到封号"
513
  errors.append({"access_token": access_token, "error": message})
 
4
  import base64
5
  import hashlib
6
  import json
 
7
  from threading import Lock
8
  from typing import Any
9
  from datetime import datetime
 
251
  message = str(exc)
252
  print(f"[account-available] refresh token={token_ref} fail {message}")
253
  if "/backend-api/me failed: HTTP 401" in message:
254
+ if self.remove_invalid_token(access_token, "refresh_account_state"):
 
255
  return None
256
  return self.update_account(
257
  access_token,
 
278
  f"status={account.get('status') if account else 'unknown'}"
279
  )
280
 
281
+ def get_text_access_token(self) -> str:
282
+ with self._lock:
283
+ for account in self._accounts:
284
+ status = self._clean_token(account.get("status"))
285
+ if status not in {"禁用", "异常"}:
286
+ return self._clean_token(account.get("access_token"))
287
+ return ""
288
+
289
+ def remove_invalid_token(self, access_token: str, event: str) -> bool:
290
+ if not config.auto_remove_invalid_accounts:
291
+ return False
292
+ removed = self.remove_token(access_token)
293
+ if removed:
294
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
295
+ return removed
296
+
297
  def next_token(self) -> str:
298
  return self.get_available_access_token()
299
 
 
519
  message = str(exc)
520
  print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
521
  if "/backend-api/me failed: HTTP 401" in message:
522
+ if not self.remove_invalid_token(access_token, "refresh_accounts"):
 
 
523
  self.update_account(access_token, {"status": "异常", "quota": 0})
524
  message = "检测到封号"
525
  errors.append({"access_token": access_token, "error": message})
services/chatgpt_service.py DELETED
@@ -1,1178 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import hashlib
5
- import re
6
- import time
7
- import uuid
8
- from pathlib import Path
9
- from typing import Any, Iterable, Iterator
10
-
11
- from fastapi import HTTPException
12
-
13
- from services.account_service import AccountService
14
- from services.anthropic_protocol import preprocess_payload
15
- from services.config import config
16
- from services.log_service import LOG_TYPE_ACCOUNT, log_service
17
- from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
18
- from utils.helper import (
19
- IMAGE_MODELS,
20
- anonymize_token,
21
- extract_chat_image,
22
- extract_chat_prompt,
23
- extract_image_from_message_content,
24
- extract_response_prompt,
25
- has_response_image_generation_tool,
26
- parse_image_count,
27
- build_chat_image_completion,
28
- )
29
- from utils.helper import is_image_chat_request
30
- from utils.log import logger
31
-
32
-
33
- class ImageGenerationError(Exception):
34
- pass
35
-
36
-
37
- def is_token_invalid_error(message: str) -> bool:
38
- text = str(message or "").lower()
39
- return (
40
- "token_invalidated" in text
41
- or "token_revoked" in text
42
- or "authentication token has been invalidated" in text
43
- or "invalidated oauth token" in text
44
- )
45
-
46
-
47
- def _save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
48
- config.cleanup_old_images()
49
- file_hash = hashlib.md5(image_data).hexdigest()
50
- timestamp = int(time.time())
51
- filename = f"{timestamp}_{file_hash}.png"
52
- relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
53
- file_path = config.images_dir / relative_dir / filename
54
- file_path.parent.mkdir(parents=True, exist_ok=True)
55
- file_path.write_bytes(image_data)
56
- return f"{(base_url or config.base_url)}/images/{relative_dir.as_posix()}/{filename}"
57
-
58
-
59
- def _extract_response_image(input_value: object) -> tuple[bytes, str] | None:
60
- if isinstance(input_value, dict):
61
- return extract_image_from_message_content(input_value.get("content"))
62
- if not isinstance(input_value, list):
63
- return None
64
- for item in reversed(input_value):
65
- if isinstance(item, dict):
66
- if str(item.get("type") or "").strip() == "input_image":
67
- import base64 as b64
68
- image_url = str(item.get("image_url") or "")
69
- if image_url.startswith("data:"):
70
- header, _, data = image_url.partition(",")
71
- mime = header.split(";")[0].removeprefix("data:")
72
- return b64.b64decode(data), mime or "image/png"
73
- content = item.get("content")
74
- if content:
75
- result = extract_image_from_message_content(content)
76
- if result:
77
- return result
78
- return None
79
-
80
-
81
- class ChatGPTService:
82
- def __init__(self, account_service: AccountService):
83
- self.account_service = account_service
84
-
85
- @staticmethod
86
- def _new_backend(access_token: str = "") -> OpenAIBackendAPI:
87
- return OpenAIBackendAPI(access_token=access_token)
88
-
89
- def _get_text_access_token(self) -> str:
90
- try:
91
- for access_token in self.account_service.list_tokens():
92
- status = str((self.account_service.get_account(access_token) or {}).get("status") or "").strip()
93
- if status not in {"禁用", "异常"}:
94
- return access_token
95
- except Exception:
96
- pass
97
- return ""
98
-
99
- @staticmethod
100
- def _encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
101
- encoded_images: list[str] = []
102
- for image_data, _, _ in images:
103
- if image_data:
104
- encoded_images.append(base64.b64encode(image_data).decode("ascii"))
105
- return encoded_images
106
-
107
- def list_models(self) -> dict[str, object]:
108
- result = self._new_backend().list_models()
109
- data = result.get("data")
110
- if not isinstance(data, list):
111
- return result
112
- seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
113
- for model in sorted(IMAGE_MODELS):
114
- if model in seen:
115
- continue
116
- data.append({
117
- "id": model,
118
- "object": "model",
119
- "created": 0,
120
- "owned_by": "chatgpt2api",
121
- "permission": [],
122
- "root": model,
123
- "parent": None,
124
- })
125
- return result
126
-
127
- @staticmethod
128
- def _chat_messages_from_body(body: dict[str, object]) -> list[dict[str, object]]:
129
- messages = body.get("messages")
130
- if isinstance(messages, list) and messages:
131
- return [message for message in messages if isinstance(message, dict)]
132
- prompt = str(body.get("prompt") or "").strip()
133
- if prompt:
134
- return [{"role": "user", "content": prompt}]
135
- raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
136
-
137
- @staticmethod
138
- def _response_messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, object]]:
139
- messages: list[dict[str, object]] = []
140
- system_text = str(instructions or "").strip()
141
- if system_text:
142
- messages.append({"role": "system", "content": system_text})
143
-
144
- if isinstance(input_value, str):
145
- user_text = input_value.strip()
146
- if user_text:
147
- messages.append({"role": "user", "content": user_text})
148
- return messages
149
-
150
- if isinstance(input_value, dict):
151
- messages.append({
152
- "role": str(input_value.get("role") or "user"),
153
- "content": extract_response_prompt([input_value]) or input_value.get("content") or "",
154
- })
155
- return messages
156
-
157
- if isinstance(input_value, list):
158
- if all(isinstance(item, dict) and item.get("type") for item in input_value):
159
- text = extract_response_prompt(input_value)
160
- if text:
161
- messages.append({"role": "user", "content": text})
162
- return messages
163
- for item in input_value:
164
- if not isinstance(item, dict):
165
- continue
166
- messages.append({
167
- "role": str(item.get("role") or "user"),
168
- "content": extract_response_prompt([item]) or item.get("content") or "",
169
- })
170
- return messages
171
-
172
- return messages
173
-
174
- @staticmethod
175
- def _response_text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, object]:
176
- return {
177
- "id": item_id or f"msg_{uuid.uuid4().hex}",
178
- "type": "message",
179
- "status": status,
180
- "role": "assistant",
181
- "content": [{
182
- "type": "output_text",
183
- "text": text,
184
- "annotations": [],
185
- }],
186
- }
187
-
188
- def _create_text_response(self, body: dict[str, object]) -> dict[str, object]:
189
- model = str(body.get("model") or "auto").strip() or "auto"
190
- messages = self._response_messages_from_input(body.get("input"), body.get("instructions"))
191
- if len(messages) == 1 and messages[0].get("role") == "system":
192
- raise HTTPException(status_code=400, detail={"error": "input text is required"})
193
- try:
194
- result = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
195
- except Exception as exc:
196
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
197
-
198
- created = int(result.get("created") or time.time())
199
- output_text = str((((result.get("choices") or [{}])[0].get("message") or {}).get("content")) or "")
200
- response_id = f"resp_{uuid.uuid4().hex}"
201
- output_item = self._response_text_output_item(output_text)
202
- return {
203
- "id": response_id,
204
- "object": "response",
205
- "created_at": created,
206
- "status": "completed",
207
- "error": None,
208
- "incomplete_details": None,
209
- "model": model,
210
- "output": [output_item],
211
- "parallel_tool_calls": False,
212
- "usage": result.get("usage"),
213
- }
214
-
215
- def _stream_text_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
216
- model = str(body.get("model") or "auto").strip() or "auto"
217
- messages = self._response_messages_from_input(body.get("input"), body.get("instructions"))
218
- if len(messages) == 1 and messages[0].get("role") == "system":
219
- raise HTTPException(status_code=400, detail={"error": "input text is required"})
220
-
221
- response_id = f"resp_{uuid.uuid4().hex}"
222
- item_id = f"msg_{uuid.uuid4().hex}"
223
- created = int(time.time())
224
- full_text = ""
225
-
226
- yield {
227
- "type": "response.created",
228
- "response": {
229
- "id": response_id,
230
- "object": "response",
231
- "created_at": created,
232
- "status": "in_progress",
233
- "error": None,
234
- "incomplete_details": None,
235
- "model": model,
236
- "output": [],
237
- "parallel_tool_calls": False,
238
- },
239
- }
240
- yield {
241
- "type": "response.output_item.added",
242
- "output_index": 0,
243
- "item": self._response_text_output_item("", item_id=item_id, status="in_progress"),
244
- }
245
-
246
- try:
247
- stream = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=True)
248
- for chunk in stream:
249
- choices = chunk.get("choices")
250
- first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
251
- delta = first_choice.get("delta") if isinstance(first_choice.get("delta"), dict) else {}
252
- delta_text = str(delta.get("content") or "")
253
- if delta_text:
254
- full_text += delta_text
255
- yield {
256
- "type": "response.output_text.delta",
257
- "item_id": item_id,
258
- "output_index": 0,
259
- "content_index": 0,
260
- "delta": delta_text,
261
- }
262
- except Exception as exc:
263
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
264
-
265
- yield {
266
- "type": "response.output_text.done",
267
- "item_id": item_id,
268
- "output_index": 0,
269
- "content_index": 0,
270
- "text": full_text,
271
- }
272
- output_item = self._response_text_output_item(full_text, item_id=item_id, status="completed")
273
- yield {
274
- "type": "response.output_item.done",
275
- "output_index": 0,
276
- "item": output_item,
277
- }
278
- yield {
279
- "type": "response.completed",
280
- "response": {
281
- "id": response_id,
282
- "object": "response",
283
- "created_at": created,
284
- "status": "completed",
285
- "error": None,
286
- "incomplete_details": None,
287
- "model": model,
288
- "output": [output_item],
289
- "parallel_tool_calls": False,
290
- },
291
- }
292
-
293
- @staticmethod
294
- def _is_text_response_request(body: dict[str, object]) -> bool:
295
- tools = body.get("tools")
296
- if isinstance(tools, list):
297
- for tool in tools:
298
- if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
299
- return False
300
- tool_choice = body.get("tool_choice")
301
- if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation":
302
- return False
303
- return True
304
-
305
- @staticmethod
306
- def _is_codex_image_response_request(body: dict[str, object]) -> bool:
307
- return has_response_image_generation_tool(body) and str(body.get("model") or "").strip() == CODEX_IMAGE_MODEL
308
-
309
- @staticmethod
310
- def _build_image_response_output(
311
- prompt: str,
312
- image_result: dict[str, object],
313
- ) -> list[dict[str, object]]:
314
- image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
315
- output: list[dict[str, object]] = []
316
- for item in image_items:
317
- if not isinstance(item, dict):
318
- continue
319
- b64_json = str(item.get("b64_json") or "").strip()
320
- if not b64_json:
321
- continue
322
- output.append(
323
- {
324
- "id": f"ig_{len(output) + 1}",
325
- "type": "image_generation_call",
326
- "status": "completed",
327
- "result": b64_json,
328
- "revised_prompt": str(item.get("revised_prompt") or prompt).strip(),
329
- }
330
- )
331
- return output
332
-
333
- def _create_token_image_response(self, body: dict[str, object]) -> dict[str, object]:
334
- prompt = extract_response_prompt(body.get("input"))
335
- if not prompt:
336
- raise HTTPException(status_code=400, detail={"error": "input text is required"})
337
-
338
- model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
339
- image_info = _extract_response_image(body.get("input"))
340
- try:
341
- if image_info:
342
- image_data, mime_type = image_info
343
- image_result = self.edit_with_pool(prompt, [(image_data, "image.png", mime_type)], model, 1)
344
- else:
345
- image_result = self.generate_with_pool(prompt, model, 1, size="1:1")
346
- except ImageGenerationError as exc:
347
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
348
-
349
- output = self._build_image_response_output(prompt, image_result)
350
- if not output:
351
- raise HTTPException(status_code=502, detail={"error": "image generation failed"})
352
-
353
- created = int(image_result.get("created") or time.time())
354
- response = {
355
- "id": f"resp_{created}",
356
- "object": "response",
357
- "created_at": created,
358
- "status": "completed",
359
- "error": None,
360
- "incomplete_details": None,
361
- "model": model,
362
- "output": output,
363
- "parallel_tool_calls": False,
364
- }
365
- return response
366
-
367
- def _stream_token_image_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
368
- prompt = extract_response_prompt(body.get("input"))
369
- if not prompt:
370
- raise HTTPException(status_code=400, detail={"error": "input text is required"})
371
-
372
- model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
373
- image_info = _extract_response_image(body.get("input"))
374
- response_id = f"resp_{uuid.uuid4().hex}"
375
- item_id = f"ig_{uuid.uuid4().hex}"
376
- created = int(time.time())
377
- final_output: list[dict[str, object]] = []
378
-
379
- yield {
380
- "type": "response.created",
381
- "response": {
382
- "id": response_id,
383
- "object": "response",
384
- "created_at": created,
385
- "status": "in_progress",
386
- "error": None,
387
- "incomplete_details": None,
388
- "model": model,
389
- "output": [],
390
- "parallel_tool_calls": False,
391
- },
392
- }
393
-
394
- try:
395
- if image_info:
396
- image_data, mime_type = image_info
397
- stream = self.stream_image_edit(prompt, [(image_data, "image.png", mime_type)], model, 1)
398
- else:
399
- stream = self.stream_image_generation(prompt, model, 1, size="1:1")
400
-
401
- for chunk in stream:
402
- data = chunk.get("data")
403
- if not isinstance(data, list) or not data:
404
- continue
405
- output = self._build_image_response_output(
406
- prompt,
407
- {
408
- "created": chunk.get("created"),
409
- "data": data,
410
- },
411
- )
412
- if output:
413
- final_output = output
414
- except ImageGenerationError as exc:
415
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
416
-
417
- if not final_output:
418
- raise HTTPException(status_code=502, detail={"error": "image generation failed"})
419
-
420
- final_item = dict(final_output[0])
421
- final_item["id"] = item_id
422
- yield {
423
- "type": "response.output_item.done",
424
- "output_index": 0,
425
- "item": final_item,
426
- }
427
- yield {
428
- "type": "response.completed",
429
- "response": {
430
- "id": response_id,
431
- "object": "response",
432
- "created_at": created,
433
- "status": "completed",
434
- "error": None,
435
- "incomplete_details": None,
436
- "model": model,
437
- "output": [final_item],
438
- "parallel_tool_calls": False,
439
- },
440
- }
441
-
442
- @staticmethod
443
- def _format_image_result(
444
- result: dict[str, object],
445
- prompt: str,
446
- response_format: str,
447
- base_url: str | None = None,
448
- ) -> dict[str, object]:
449
- created = result.get("created")
450
- data = result.get("data")
451
- formatted_items: list[dict[str, object]] = []
452
- if isinstance(data, list):
453
- for item in data:
454
- if not isinstance(item, dict):
455
- continue
456
- revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
457
- b64_json = str(item.get("b64_json") or "").strip()
458
- if response_format == "b64_json":
459
- if b64_json:
460
- image_url = _save_image_bytes(base64.b64decode(b64_json), base_url)
461
- formatted_items.append({"b64_json": b64_json, "url": image_url, "revised_prompt": revised_prompt})
462
- continue
463
- if not b64_json:
464
- continue
465
- image_data = base64.b64decode(b64_json)
466
- formatted_items.append(
467
- {"url": _save_image_bytes(image_data, base_url), "revised_prompt": revised_prompt})
468
- return {"created": created, "data": formatted_items}
469
-
470
- def _remove_invalid_token(self, access_token: str, event: str) -> bool:
471
- if not config.auto_remove_invalid_accounts:
472
- return False
473
- removed = self.account_service.remove_token(access_token)
474
- if removed:
475
- log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
476
- return removed
477
-
478
- @staticmethod
479
- def _extract_image_data_urls(markdown_content: str) -> list[str]:
480
- return re.findall(r"!\[[^\]]*\]\((data:image/[^;]+;base64,[^)]+)\)", markdown_content or "")
481
-
482
- def _stream_result_from_markdown(
483
- self,
484
- markdown_content: str,
485
- prompt: str,
486
- response_format: str,
487
- base_url: str | None = None,
488
- created: int | None = None,
489
- ) -> dict[str, object] | None:
490
- data_urls = self._extract_image_data_urls(markdown_content)
491
- if not data_urls:
492
- return None
493
- raw_items: list[dict[str, object]] = []
494
- for data_url in data_urls:
495
- header, _, data = data_url.partition(",")
496
- mime_type = header.split(";")[0].removeprefix("data:") or "image/png"
497
- raw_items.append({
498
- "b64_json": data,
499
- "revised_prompt": prompt,
500
- "mime_type": mime_type,
501
- })
502
- return self._format_image_result(
503
- {"created": created or int(time.time()), "data": raw_items},
504
- prompt,
505
- response_format,
506
- base_url,
507
- )
508
-
509
- @staticmethod
510
- def _progress_chunk(
511
- model: str,
512
- index: int,
513
- total: int,
514
- created: int | None = None,
515
- progress_text: str = "",
516
- upstream_event_type: str = "",
517
- ) -> dict[str, object]:
518
- return {
519
- "object": "image.generation.chunk",
520
- "created": created or int(time.time()),
521
- "model": model,
522
- "index": index,
523
- "total": total,
524
- "progress_text": progress_text,
525
- "upstream_event_type": upstream_event_type,
526
- "data": [],
527
- }
528
-
529
- def _stream_single_image_result(
530
- self,
531
- prompt: str,
532
- model: str,
533
- index: int,
534
- total: int,
535
- request_token: str,
536
- size: str | None = None,
537
- response_format: str = "b64_json",
538
- base_url: str | None = None,
539
- images: list[str] | None = None,
540
- ) -> Iterator[dict[str, object]]:
541
- stream = self._new_backend(request_token).stream_image_chat_completions(
542
- prompt=prompt,
543
- model=model,
544
- size=size,
545
- images=images or None,
546
- )
547
- for chunk in stream:
548
- created = int(chunk.get("created") or time.time()) if isinstance(chunk, dict) else int(time.time())
549
- choices = chunk.get("choices") if isinstance(chunk, dict) else None
550
- first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
551
- delta = first_choice.get("delta") if isinstance(first_choice.get("delta"), dict) else {}
552
- content = str(delta.get("content") or "")
553
- finish_reason = str(first_choice.get("finish_reason") or "")
554
-
555
- if "upstream_event" in chunk:
556
- upstream_event = chunk.get("upstream_event")
557
- upstream_event_type = ""
558
- if isinstance(upstream_event, dict):
559
- upstream_event_type = str(upstream_event.get("type") or "")
560
- yield self._progress_chunk(model, index, total, created, content, upstream_event_type)
561
- continue
562
-
563
- formatted_result = self._stream_result_from_markdown(content, prompt, response_format, base_url, created)
564
- if formatted_result:
565
- yield {
566
- "object": "image.generation.result",
567
- "created": formatted_result.get("created"),
568
- "model": model,
569
- "index": index,
570
- "total": total,
571
- "data": formatted_result.get("data") if isinstance(formatted_result.get("data"), list) else [],
572
- }
573
- continue
574
-
575
- if finish_reason:
576
- yield {
577
- "object": "image.generation.done",
578
- "created": created,
579
- "model": model,
580
- "index": index,
581
- "total": total,
582
- "data": [],
583
- "finish_reason": finish_reason,
584
- }
585
-
586
- def _iter_generated_images_with_pool(
587
- self,
588
- prompt: str,
589
- model: str,
590
- n: int,
591
- size: str | None = None,
592
- response_format: str = "b64_json",
593
- base_url: str | None = None,
594
- ) -> Iterator[dict[str, object]]:
595
- emitted = False
596
- last_error = ""
597
- for index in range(1, n + 1):
598
- while True:
599
- try:
600
- request_token = self.account_service.get_available_access_token()
601
- except RuntimeError as exc:
602
- last_error = str(exc)
603
- logger.warning({
604
- "event": "image_generate_stop",
605
- "index": index,
606
- "total": n,
607
- "error": last_error,
608
- })
609
- if emitted:
610
- return
611
- raise ImageGenerationError(last_error or "image generation failed") from exc
612
-
613
- logger.info({
614
- "event": "image_generate_start",
615
- "request_token": request_token,
616
- "model": model,
617
- "index": index,
618
- "total": n,
619
- })
620
- try:
621
- result = self._format_image_result(self._new_backend(request_token).images_generations(
622
- prompt=prompt,
623
- model=model,
624
- size=size,
625
- response_format="b64_json",
626
- ), prompt, response_format, base_url)
627
- account = self.account_service.mark_image_result(request_token, success=True)
628
- data = result.get("data")
629
- image_items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
630
- logger.info({
631
- "event": "image_generate_success",
632
- "request_token": request_token,
633
- "quota": account.get("quota") if account else "unknown",
634
- "status": account.get("status") if account else "unknown",
635
- })
636
- if image_items:
637
- emitted = True
638
- yield {
639
- "created": result.get("created"),
640
- "data": image_items,
641
- }
642
- break
643
- except Exception as exc:
644
- account = self.account_service.mark_image_result(request_token, success=False)
645
- message = str(exc)
646
- last_error = message
647
- logger.warning({
648
- "event": "image_generate_fail",
649
- "request_token": request_token,
650
- "error": message,
651
- "quota": account.get("quota") if account else "unknown",
652
- "status": account.get("status") if account else "unknown",
653
- })
654
- if is_token_invalid_error(message):
655
- self._remove_invalid_token(request_token, "image_generate")
656
- logger.warning({
657
- "event": "image_generate_remove_invalid_token",
658
- "request_token": request_token,
659
- })
660
- continue
661
- break
662
-
663
- if not emitted:
664
- raise ImageGenerationError(last_error or "image generation failed")
665
-
666
- def generate_with_pool(self, prompt: str, model: str, n: int, size: str | None = None, response_format: str = "b64_json",
667
- base_url: str = None):
668
- created = None
669
- image_items: list[dict[str, object]] = []
670
- for result in self._iter_generated_images_with_pool(prompt, model, n, size, response_format, base_url):
671
- if created is None:
672
- created = result.get("created")
673
- data = result.get("data")
674
- if isinstance(data, list):
675
- image_items.extend(item for item in data if isinstance(item, dict))
676
- result = {
677
- "created": created,
678
- "data": image_items,
679
- }
680
- return result
681
-
682
- def stream_image_generation(
683
- self,
684
- prompt: str,
685
- model: str,
686
- n: int,
687
- size: str | None = None,
688
- response_format: str = "b64_json",
689
- base_url: str | None = None,
690
- ) -> Iterator[dict[str, object]]:
691
- last_error = ""
692
- emitted = False
693
- for index in range(1, n + 1):
694
- while True:
695
- try:
696
- request_token = self.account_service.get_available_access_token()
697
- except RuntimeError as exc:
698
- last_error = str(exc)
699
- logger.warning({
700
- "event": "image_generate_stream_stop",
701
- "index": index,
702
- "total": n,
703
- "error": last_error,
704
- })
705
- if emitted:
706
- return
707
- raise ImageGenerationError(last_error or "image generation failed") from exc
708
-
709
- logger.info({
710
- "event": "image_generate_stream_start",
711
- "request_token": request_token,
712
- "model": model,
713
- "index": index,
714
- "total": n,
715
- })
716
- emitted_for_request = False
717
- has_result = False
718
- try:
719
- for chunk in self._stream_single_image_result(
720
- prompt,
721
- model,
722
- index,
723
- n,
724
- request_token,
725
- size,
726
- response_format,
727
- base_url,
728
- ):
729
- emitted = True
730
- emitted_for_request = True
731
- data = chunk.get("data")
732
- if isinstance(data, list) and data:
733
- has_result = True
734
- yield chunk
735
- if not has_result:
736
- last_error = "image generation failed"
737
- raise ImageGenerationError(last_error)
738
- account = self.account_service.mark_image_result(request_token, success=True)
739
- logger.info({
740
- "event": "image_generate_stream_success",
741
- "request_token": request_token,
742
- "quota": account.get("quota") if account else "unknown",
743
- "status": account.get("status") if account else "unknown",
744
- "has_result": has_result,
745
- })
746
- break
747
- except Exception as exc:
748
- account = self.account_service.mark_image_result(request_token, success=False)
749
- message = str(exc)
750
- last_error = message
751
- logger.warning({
752
- "event": "image_generate_stream_fail",
753
- "request_token": request_token,
754
- "error": message,
755
- "quota": account.get("quota") if account else "unknown",
756
- "status": account.get("status") if account else "unknown",
757
- })
758
- if not emitted_for_request and is_token_invalid_error(message):
759
- self._remove_invalid_token(request_token, "image_generate_stream")
760
- logger.warning({
761
- "event": "image_generate_stream_remove_invalid_token",
762
- "request_token": request_token,
763
- })
764
- continue
765
- raise ImageGenerationError(last_error or "image generation failed") from exc
766
-
767
- def edit_with_pool(
768
- self,
769
- prompt: str,
770
- images: Iterable[tuple[bytes, str, str]],
771
- model: str,
772
- n: int,
773
- size: str | None = None,
774
- response_format: str = "b64_json",
775
- base_url: str = None,
776
- ):
777
- created = None
778
- image_items: list[dict[str, object]] = []
779
- last_error = ""
780
- normalized_images = list(images)
781
- if not normalized_images:
782
- raise ImageGenerationError("image is required")
783
-
784
- for index in range(1, n + 1):
785
- while True:
786
- try:
787
- request_token = self.account_service.get_available_access_token()
788
- except RuntimeError as exc:
789
- last_error = str(exc)
790
- logger.warning({
791
- "event": "image_edit_stop",
792
- "index": index,
793
- "total": n,
794
- "error": last_error,
795
- })
796
- break
797
-
798
- logger.info({
799
- "event": "image_edit_start",
800
- "request_token": request_token,
801
- "model": model,
802
- "index": index,
803
- "total": n,
804
- "image_count": len(normalized_images),
805
- })
806
- try:
807
- result = self._format_image_result(self._new_backend(request_token).images_edits(
808
- image=self._encode_images(normalized_images),
809
- prompt=prompt,
810
- model=model,
811
- size=size,
812
- response_format="b64_json",
813
- ), prompt, response_format, base_url)
814
- account = self.account_service.mark_image_result(request_token, success=True)
815
- if created is None:
816
- created = result.get("created")
817
- data = result.get("data")
818
- if isinstance(data, list):
819
- image_items.extend(item for item in data if isinstance(item, dict))
820
- logger.info({
821
- "event": "image_edit_success",
822
- "request_token": request_token,
823
- "quota": account.get("quota") if account else "unknown",
824
- "status": account.get("status") if account else "unknown",
825
- })
826
- break
827
- except Exception as exc:
828
- account = self.account_service.mark_image_result(request_token, success=False)
829
- message = str(exc)
830
- last_error = message
831
- logger.warning({
832
- "event": "image_edit_fail",
833
- "request_token": request_token,
834
- "error": message,
835
- "quota": account.get("quota") if account else "unknown",
836
- "status": account.get("status") if account else "unknown",
837
- })
838
- if is_token_invalid_error(message):
839
- self._remove_invalid_token(request_token, "image_edit")
840
- logger.warning({
841
- "event": "image_edit_remove_invalid_token",
842
- "request_token": request_token,
843
- })
844
- continue
845
- break
846
-
847
- if not image_items:
848
- raise ImageGenerationError(last_error or "image edit failed")
849
-
850
- result = {
851
- "created": created,
852
- "data": image_items,
853
- }
854
- return result
855
-
856
- def stream_image_edit(
857
- self,
858
- prompt: str,
859
- images: Iterable[tuple[bytes, str, str]],
860
- model: str,
861
- n: int,
862
- size: str | None = None,
863
- response_format: str = "b64_json",
864
- base_url: str | None = None,
865
- ) -> Iterator[dict[str, object]]:
866
- last_error = ""
867
- emitted = False
868
- normalized_images = list(images)
869
- if not normalized_images:
870
- raise ImageGenerationError("image is required")
871
- encoded_images = self._encode_images(normalized_images)
872
-
873
- for index in range(1, n + 1):
874
- while True:
875
- try:
876
- request_token = self.account_service.get_available_access_token()
877
- except RuntimeError as exc:
878
- last_error = str(exc)
879
- logger.warning({
880
- "event": "image_edit_stream_stop",
881
- "index": index,
882
- "total": n,
883
- "error": last_error,
884
- })
885
- if emitted:
886
- return
887
- raise ImageGenerationError(last_error or "image edit failed") from exc
888
-
889
- logger.info({
890
- "event": "image_edit_stream_start",
891
- "request_token": request_token,
892
- "model": model,
893
- "index": index,
894
- "total": n,
895
- "image_count": len(normalized_images),
896
- })
897
- emitted_for_request = False
898
- has_result = False
899
- try:
900
- for chunk in self._stream_single_image_result(
901
- prompt=prompt,
902
- model=model,
903
- index=index,
904
- total=n,
905
- request_token=request_token,
906
- size=size,
907
- response_format=response_format,
908
- base_url=base_url,
909
- images=encoded_images,
910
- ):
911
- emitted = True
912
- emitted_for_request = True
913
- data = chunk.get("data")
914
- if isinstance(data, list) and data:
915
- has_result = True
916
- yield chunk
917
- if not has_result:
918
- last_error = "image edit failed"
919
- raise ImageGenerationError(last_error)
920
- account = self.account_service.mark_image_result(request_token, success=True)
921
- logger.info({
922
- "event": "image_edit_stream_success",
923
- "request_token": request_token,
924
- "quota": account.get("quota") if account else "unknown",
925
- "status": account.get("status") if account else "unknown",
926
- "has_result": has_result,
927
- })
928
- break
929
- except Exception as exc:
930
- account = self.account_service.mark_image_result(request_token, success=False)
931
- message = str(exc)
932
- last_error = message
933
- logger.warning({
934
- "event": "image_edit_stream_fail",
935
- "request_token": request_token,
936
- "error": message,
937
- "quota": account.get("quota") if account else "unknown",
938
- "status": account.get("status") if account else "unknown",
939
- })
940
- if not emitted_for_request and is_token_invalid_error(message):
941
- self._remove_invalid_token(request_token, "image_edit_stream")
942
- logger.warning({
943
- "event": "image_edit_stream_remove_invalid_token",
944
- "request_token": request_token,
945
- })
946
- continue
947
- raise ImageGenerationError(last_error or "image edit failed") from exc
948
-
949
- @staticmethod
950
- def _stream_completion_response(result: dict[str, object]) -> Iterator[dict[str, object]]:
951
- completion_id = str(result.get("id") or f"chatcmpl-{uuid.uuid4().hex}")
952
- created = int(result.get("created") or time.time())
953
- model = str(result.get("model") or "auto")
954
- choices = result.get("choices")
955
- first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
956
- message = first_choice.get("message") if isinstance(first_choice.get("message"), dict) else {}
957
- content = str(message.get("content") or "")
958
- finish_reason = str(first_choice.get("finish_reason") or "stop")
959
-
960
- yield {
961
- "id": completion_id,
962
- "object": "chat.completion.chunk",
963
- "created": created,
964
- "model": model,
965
- "choices": [{
966
- "index": 0,
967
- "delta": {"role": "assistant", "content": content},
968
- "finish_reason": None,
969
- }],
970
- }
971
- yield {
972
- "id": completion_id,
973
- "object": "chat.completion.chunk",
974
- "created": created,
975
- "model": model,
976
- "choices": [{
977
- "index": 0,
978
- "delta": {},
979
- "finish_reason": finish_reason,
980
- }],
981
- }
982
-
983
- def _create_image_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
984
- model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
985
- n = parse_image_count(body.get("n"))
986
- prompt = extract_chat_prompt(body)
987
- if not prompt:
988
- raise HTTPException(status_code=400, detail={"error": "prompt is required"})
989
-
990
- image_infos = extract_chat_image(body)
991
- try:
992
- if image_infos:
993
- images = [(data, f"image_{idx}.png", mime) for idx, (data, mime) in enumerate(image_infos, start=1)]
994
- image_result = self.edit_with_pool(prompt, images, model, n)
995
- else:
996
- image_result = self.generate_with_pool(prompt, model, n)
997
- except ImageGenerationError as exc:
998
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
999
-
1000
- return build_chat_image_completion(model, image_result)
1001
-
1002
- def _stream_image_chat_completion(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
1003
- model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
1004
- n = parse_image_count(body.get("n"))
1005
- if n != 1:
1006
- result = self._create_image_chat_completion(body)
1007
- yield from self._stream_completion_response(result)
1008
- return
1009
-
1010
- prompt = extract_chat_prompt(body)
1011
- if not prompt:
1012
- raise HTTPException(status_code=400, detail={"error": "prompt is required"})
1013
-
1014
- image_infos = extract_chat_image(body)
1015
- encoded_images = []
1016
- if image_infos:
1017
- images = [(data, f"image_{idx}.png", mime) for idx, (data, mime) in enumerate(image_infos, start=1)]
1018
- encoded_images = self._encode_images(images)
1019
-
1020
- last_error = ""
1021
- while True:
1022
- try:
1023
- request_token = self.account_service.get_available_access_token()
1024
- except RuntimeError as exc:
1025
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1026
-
1027
- logger.info({
1028
- "event": "image_stream_start",
1029
- "request_token": request_token,
1030
- "model": model,
1031
- })
1032
- emitted = False
1033
- try:
1034
- stream = self._new_backend(request_token).stream_image_chat_completions(
1035
- prompt=prompt,
1036
- model=model,
1037
- images=encoded_images or None,
1038
- )
1039
- for chunk in stream:
1040
- emitted = True
1041
- yield chunk
1042
- account = self.account_service.mark_image_result(request_token, success=True)
1043
- logger.info({
1044
- "event": "image_stream_success",
1045
- "request_token": request_token,
1046
- "quota": account.get("quota") if account else "unknown",
1047
- "status": account.get("status") if account else "unknown",
1048
- })
1049
- return
1050
- except Exception as exc:
1051
- account = self.account_service.mark_image_result(request_token, success=False)
1052
- message = str(exc)
1053
- last_error = message
1054
- logger.warning({
1055
- "event": "image_stream_fail",
1056
- "request_token": request_token,
1057
- "error": message,
1058
- "quota": account.get("quota") if account else "unknown",
1059
- "status": account.get("status") if account else "unknown",
1060
- })
1061
- if not emitted and is_token_invalid_error(message):
1062
- self._remove_invalid_token(request_token, "image_stream")
1063
- logger.warning({
1064
- "event": "image_stream_remove_invalid_token",
1065
- "request_token": request_token,
1066
- })
1067
- continue
1068
- raise HTTPException(status_code=502, detail={"error": last_error or "image generation failed"}) from exc
1069
-
1070
- def _create_text_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
1071
- model = str(body.get("model") or "auto").strip() or "auto"
1072
- messages = self._chat_messages_from_body(body)
1073
- try:
1074
- result = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
1075
- return result
1076
- except Exception as exc:
1077
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1078
-
1079
- def create_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
1080
- if is_image_chat_request(body):
1081
- return self._create_image_chat_completion(body)
1082
- return self._create_text_chat_completion(body)
1083
-
1084
- def stream_chat_completion(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
1085
- if is_image_chat_request(body):
1086
- yield from self._stream_image_chat_completion(body)
1087
- return
1088
-
1089
- model = str(body.get("model") or "auto").strip() or "auto"
1090
- messages = self._chat_messages_from_body(body)
1091
- try:
1092
- yield from self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=True)
1093
- except Exception as exc:
1094
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1095
-
1096
- def create_message(self, body: dict[str, object]) -> dict[str, object]:
1097
- body = preprocess_payload(dict(body))
1098
- model = str(body.get("model") or "auto").strip() or "auto"
1099
- messages = self._chat_messages_from_body(body)
1100
- try:
1101
- result = self._new_backend(self._get_text_access_token()).messages(
1102
- messages=messages,
1103
- model=model,
1104
- stream=False,
1105
- system=body.get("system"),
1106
- tools=body.get("tools"),
1107
- )
1108
- return result
1109
- except Exception as exc:
1110
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1111
-
1112
- def stream_message(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
1113
- body = preprocess_payload(dict(body))
1114
- model = str(body.get("model") or "auto").strip() or "auto"
1115
- messages = self._chat_messages_from_body(body)
1116
- try:
1117
- yield from self._new_backend(self._get_text_access_token()).messages(
1118
- messages=messages,
1119
- model=model,
1120
- stream=True,
1121
- system=body.get("system"),
1122
- tools=body.get("tools"),
1123
- )
1124
- except Exception as exc:
1125
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1126
-
1127
- def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
1128
- if not is_image_chat_request(body):
1129
- raise HTTPException(
1130
- status_code=400,
1131
- detail={"error": "only image generation requests are supported on this endpoint"},
1132
- )
1133
- return self._create_image_chat_completion(body)
1134
-
1135
- def _get_response_access_token(self, body: dict[str, object]) -> str:
1136
- return self.account_service.get_available_access_token()
1137
-
1138
- def stream_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
1139
- if self._is_text_response_request(body):
1140
- yield from self._stream_text_response(body)
1141
- return
1142
- if not self._is_codex_image_response_request(body):
1143
- yield from self._stream_token_image_response(body)
1144
- return
1145
- try:
1146
- access_token = self._get_response_access_token(body)
1147
- yield from self._new_backend(access_token).responses(
1148
- input=body.get("input") or "",
1149
- model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
1150
- tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
1151
- instructions=str(body.get("instructions") or "you are a helpful assistant"),
1152
- tool_choice=body.get("tool_choice") or "auto",
1153
- stream=True,
1154
- store=bool(body.get("store")),
1155
- )
1156
- except Exception as exc:
1157
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
1158
-
1159
- def create_response(self, body: dict[str, object]) -> dict[str, object]:
1160
- if self._is_text_response_request(body):
1161
- result = self._create_text_response(body)
1162
- return result
1163
- if not self._is_codex_image_response_request(body):
1164
- return self._create_token_image_response(body)
1165
- try:
1166
- access_token = self._get_response_access_token(body)
1167
- result = self._new_backend(access_token).responses(
1168
- input=body.get("input") or "",
1169
- model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
1170
- tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
1171
- instructions=str(body.get("instructions") or "you are a helpful assistant"),
1172
- tool_choice=body.get("tool_choice") or "auto",
1173
- stream=False,
1174
- store=bool(body.get("store")),
1175
- )
1176
- return result
1177
- except Exception as exc:
1178
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/config.py CHANGED
@@ -117,6 +117,14 @@ class ConfigStore:
117
  return value.strip().lower() in {"1", "true", "yes", "on"}
118
  return bool(value)
119
 
 
 
 
 
 
 
 
 
120
  @property
121
  def images_dir(self) -> Path:
122
  path = DATA_DIR / "images"
@@ -158,6 +166,7 @@ class ConfigStore:
158
  data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
159
  data["image_retention_days"] = self.image_retention_days
160
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
 
161
  data.pop("auth-key", None)
162
  return data
163
 
 
117
  return value.strip().lower() in {"1", "true", "yes", "on"}
118
  return bool(value)
119
 
120
+ @property
121
+ def log_levels(self) -> list[str]:
122
+ levels = self.data.get("log_levels")
123
+ if not isinstance(levels, list):
124
+ return []
125
+ allowed = {"debug", "info", "warning", "error"}
126
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
127
+
128
  @property
129
  def images_dir(self) -> Path:
130
  path = DATA_DIR / "images"
 
166
  data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
167
  data["image_retention_days"] = self.image_retention_days
168
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
169
+ data["log_levels"] = self.log_levels
170
  data.pop("auth-key", None)
171
  return data
172
 
services/log_service.py CHANGED
@@ -1,15 +1,23 @@
1
  from __future__ import annotations
2
 
3
  import json
 
 
 
4
  from datetime import datetime
5
  from pathlib import Path
6
  from typing import Any
7
 
 
 
 
8
  from services.config import DATA_DIR
 
9
 
10
  LOG_TYPE_CALL = "call"
11
  LOG_TYPE_ACCOUNT = "account"
12
 
 
13
  class LogService:
14
  def __init__(self, path: Path):
15
  self.path = path
@@ -49,3 +57,119 @@ class LogService:
49
 
50
 
51
  log_service = LogService(DATA_DIR / "logs.jsonl")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import itertools
5
+ import time
6
+ from dataclasses import dataclass, field
7
  from datetime import datetime
8
  from pathlib import Path
9
  from typing import Any
10
 
11
+ from fastapi.concurrency import run_in_threadpool
12
+ from fastapi.responses import JSONResponse, StreamingResponse
13
+
14
  from services.config import DATA_DIR
15
+ from utils.helper import anthropic_sse_stream, sse_json_stream
16
 
17
  LOG_TYPE_CALL = "call"
18
  LOG_TYPE_ACCOUNT = "account"
19
 
20
+
21
  class LogService:
22
  def __init__(self, path: Path):
23
  self.path = path
 
57
 
58
 
59
  log_service = LogService(DATA_DIR / "logs.jsonl")
60
+
61
+
62
+ def _collect_urls(value: object) -> list[str]:
63
+ urls: list[str] = []
64
+ if isinstance(value, dict):
65
+ for key, item in value.items():
66
+ if key == "url" and isinstance(item, str):
67
+ urls.append(item)
68
+ elif key == "urls" and isinstance(item, list):
69
+ urls.extend(str(url) for url in item if isinstance(url, str))
70
+ else:
71
+ urls.extend(_collect_urls(item))
72
+ elif isinstance(value, list):
73
+ for item in value:
74
+ urls.extend(_collect_urls(item))
75
+ return urls
76
+
77
+
78
+ def _image_error_response(exc: Exception) -> JSONResponse:
79
+ message = str(exc)
80
+ if "no available image quota" in message.lower():
81
+ return JSONResponse(
82
+ status_code=429,
83
+ content={
84
+ "error": {
85
+ "message": "no available image quota",
86
+ "type": "insufficient_quota",
87
+ "param": None,
88
+ "code": "insufficient_quota",
89
+ }
90
+ },
91
+ )
92
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
93
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
94
+ return JSONResponse(
95
+ status_code=502,
96
+ content={
97
+ "error": {
98
+ "message": message,
99
+ "type": "server_error",
100
+ "param": None,
101
+ "code": "upstream_error",
102
+ }
103
+ },
104
+ )
105
+
106
+
107
+ @dataclass
108
+ class LoggedCall:
109
+ identity: dict[str, object]
110
+ endpoint: str
111
+ model: str
112
+ summary: str
113
+ started: float = field(default_factory=time.time)
114
+
115
+ async def run(self, handler, *args, sse: str = "openai"):
116
+ from services.protocol.conversation import ImageGenerationError
117
+
118
+ try:
119
+ result = await run_in_threadpool(handler, *args)
120
+ except ImageGenerationError as exc:
121
+ self.log("调用失败", status="failed", error=str(exc))
122
+ return _image_error_response(exc)
123
+ except Exception as exc:
124
+ self.log("调用失败", status="failed", error=str(exc))
125
+ raise
126
+
127
+ if isinstance(result, dict):
128
+ self.log("调用完成", result)
129
+ return result
130
+
131
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
132
+ try:
133
+ first = next(result)
134
+ except StopIteration:
135
+ self.log("流式调用结束")
136
+ return StreamingResponse(sender(()), media_type="text/event-stream")
137
+ except ImageGenerationError as exc:
138
+ self.log("调用失败", status="failed", error=str(exc))
139
+ return _image_error_response(exc)
140
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
141
+
142
+ def stream(self, items):
143
+ urls: list[str] = []
144
+ failed = False
145
+ try:
146
+ for item in items:
147
+ urls.extend(_collect_urls(item))
148
+ yield item
149
+ except Exception as exc:
150
+ failed = True
151
+ self.log("流式调用失败", status="failed", error=str(exc), urls=urls)
152
+ raise
153
+ finally:
154
+ if not failed:
155
+ self.log("流式调用结束", urls=urls)
156
+
157
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
158
+ urls: list[str] | None = None) -> None:
159
+ detail = {
160
+ "key_id": self.identity.get("id"),
161
+ "key_name": self.identity.get("name"),
162
+ "role": self.identity.get("role"),
163
+ "endpoint": self.endpoint,
164
+ "model": self.model,
165
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
166
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
167
+ "duration_ms": int((time.time() - self.started) * 1000),
168
+ "status": status,
169
+ }
170
+ if error:
171
+ detail["error"] = error
172
+ collected_urls = [*(urls or []), *_collect_urls(result)]
173
+ if collected_urls:
174
+ detail["urls"] = list(dict.fromkeys(collected_urls))
175
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
services/openai_backend_api.py CHANGED
@@ -1,5 +1,4 @@
1
  import base64
2
- import json
3
  import os
4
  import re
5
  import time
@@ -8,15 +7,12 @@ from io import BytesIO
8
  from pathlib import Path
9
  from typing import Any, Dict, Iterator, Optional
10
 
11
- import tiktoken
12
  from curl_cffi import requests
13
  from PIL import Image
14
 
15
  from services.account_service import account_service
16
- from services.anthropic_protocol import message_response, stream_events
17
- from services.config import config
18
  from services.proxy_service import proxy_settings
19
- from utils.helper import build_chat_image_markdown_content, ensure_ok, new_uuid, parse_sse_lines
20
  from utils.log import logger
21
  from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
22
  from utils.turnstile import solve_turnstile_token
@@ -36,7 +32,6 @@ DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
36
  DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
37
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
38
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
39
- CODEX_RESPONSE_MODEL = "gpt-5.4"
40
 
41
 
42
  class OpenAIBackendAPI:
@@ -47,8 +42,8 @@ class OpenAIBackendAPI:
47
  例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
48
  - 不传 `access_token` 时,会走未登录链路
49
  例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
50
- - 对外统一调用 `list_models()`、`chat_completions(...)`
51
- - 图片相关接口 `images_generations(...)`、`images_edits(...)` 目前只支持登录态
52
  """
53
 
54
  def __init__(self, access_token: str = "") -> None:
@@ -155,31 +150,6 @@ class OpenAIBackendAPI:
155
  "Upgrade-Insecure-Requests": "1",
156
  }
157
 
158
- def _build_requirements_token(self) -> str:
159
- """生成 sentinel 接口需要的旧版 requirements token。"""
160
- return build_legacy_requirements_token(
161
- self.user_agent,
162
- script_sources=self.pow_script_sources,
163
- data_build=self.pow_data_build,
164
- )
165
-
166
- def _get_token_info(self) -> Dict[str, Any]:
167
- """从 access token 的 JWT payload 中提取后续请求可能会用到的信息。"""
168
- if not self.access_token:
169
- return {}
170
- parts = self.access_token.split(".")
171
- if len(parts) < 2:
172
- return {}
173
- payload = parts[1]
174
- padding = "=" * (-len(payload) % 4)
175
- try:
176
- decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8")
177
- data = json.loads(decoded)
178
- except Exception:
179
- return {}
180
- auth_data = data.get("https://api.openai.com/auth") or {}
181
- return auth_data
182
-
183
  def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
184
  """把 sentinel 响应整理成后续对话需要的 token 集合。"""
185
  if (data.get("arkose") or {}).get("required"):
@@ -271,58 +241,17 @@ class OpenAIBackendAPI:
271
  },
272
  }
273
 
274
- def _normalize_models(self, payload: Dict[str, Any]) -> Dict[str, Any]:
275
- """把 web models 响应整理成 OpenAI `/v1/models` 风格结构。"""
276
- data = []
277
- seen = set()
278
- for item in payload.get("models", []):
279
- if not isinstance(item, dict):
280
- continue
281
- slug = str(item.get("slug", "")).strip()
282
- if not slug or slug in seen:
283
- continue
284
- seen.add(slug)
285
- data.append({
286
- "id": slug,
287
- "object": "model",
288
- "created": int(item.get("created") or 0),
289
- "owned_by": str(item.get("owned_by") or "chatgpt"),
290
- "permission": [],
291
- "root": slug,
292
- "parent": None,
293
- })
294
- data.sort(key=lambda item: item["id"])
295
- return {"object": "list", "data": data}
296
-
297
- def _build_image_prompt(self, prompt: str, size: str | None) -> str:
298
- """把标准图片 prompt 和宽高比转成底层图片生成 prompt。"""
299
- if not size:
300
- return prompt
301
- if size not in {"1:1", "16:9", "9:16", "4:3", "3:4"}:
302
- return f"{prompt.strip()}\n\n输出图片,宽高比为 {size}。"
303
- hint = {
304
- "1:1": "输出为 1:1 正方形构图,主体居中,适合正方形画幅。",
305
- "16:9": "输出为 16:9 横屏构图,适合宽画幅展示。",
306
- "9:16": "输出为 9:16 竖屏构图,适合竖版画幅展示。",
307
- "4:3": "输出为 4:3 比例,兼顾宽度与高度,适合展示画面细节。",
308
- "3:4": "输出为 3:4 比例,纵向构图,适合人物肖像或竖向场景。",
309
- }[size]
310
- return f"{prompt.strip()}\n\n{hint}"
311
-
312
  def _image_model_slug(self, model: str) -> str:
313
  """把标准图片模型名映射到底层 model slug。"""
314
  model = str(model or "").strip()
315
  if not model:
316
  return "auto"
317
- if model in {"gpt-image-1", "gpt-image-2", "gpt-image"}:
318
  return "gpt-5-3"
319
  if model == CODEX_IMAGE_MODEL:
320
  return model
321
  return "auto"
322
 
323
- def _is_codex_image_model(self, model: str) -> bool:
324
- return model == CODEX_IMAGE_MODEL
325
-
326
  def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
327
  Dict[str, str]:
328
  """构造图片链路请求头。"""
@@ -385,16 +314,6 @@ class OpenAIBackendAPI:
385
  payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
386
  return base64.b64decode(payload)
387
 
388
- def _image_to_data_url(self, image: str) -> str:
389
- """把本地图片路径或 base64 图片统一转成 data URL。"""
390
- data = self._decode_image_base64(image)
391
- try:
392
- opened = Image.open(BytesIO(data))
393
- mime_type = Image.MIME.get(opened.format, "image/png")
394
- except Exception:
395
- mime_type = "image/png"
396
- return f"data:{mime_type};base64,{base64.b64encode(data).decode()}"
397
-
398
  def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
399
  """上传一张 base64 图片,返回底层文件元数据。"""
400
  data = self._decode_image_base64(image)
@@ -528,32 +447,6 @@ class OpenAIBackendAPI:
528
  ensure_ok(response, path)
529
  return response
530
 
531
- def _parse_image_sse(self, response: requests.Response) -> Dict[str, Any]:
532
- """从图片 SSE 里提取 conversation_id、file_ids、sediment_ids。"""
533
- conversation_id = ""
534
- file_ids: list[str] = []
535
- sediment_ids: list[str] = []
536
- for raw_line in response.iter_lines():
537
- if not raw_line:
538
- continue
539
- line = raw_line.decode("utf-8", errors="ignore")
540
- if not line.startswith("data:"):
541
- continue
542
- payload = line[5:].strip()
543
- if payload == "[DONE]":
544
- break
545
- if not conversation_id:
546
- match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
547
- if match:
548
- conversation_id = match.group(1)
549
- for file_id in re.findall(r"(file[-_][A-Za-z0-9]+)", payload):
550
- if file_id not in file_ids:
551
- file_ids.append(file_id)
552
- for sediment_id in re.findall(r"sediment://([A-Za-z0-9_-]+)", payload):
553
- if sediment_id not in sediment_ids:
554
- sediment_ids.append(sediment_id)
555
- return {"conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids}
556
-
557
  def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
558
  """获取完整 conversation 详情。"""
559
  path = f"/backend-api/conversation/{conversation_id}"
@@ -597,8 +490,10 @@ class OpenAIBackendAPI:
597
  def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
598
  """轮询 conversation,直到拿到图片文件 id 或超时。"""
599
  start = time.time()
600
- last_sediment_ids: list[str] = []
 
601
  while time.time() - start < timeout_secs:
 
602
  conversation = self._get_conversation(conversation_id)
603
  file_ids, sediment_ids = [], []
604
  for record in self._extract_image_tool_records(conversation):
@@ -608,12 +503,17 @@ class OpenAIBackendAPI:
608
  for sediment_id in record["sediment_ids"]:
609
  if sediment_id not in sediment_ids:
610
  sediment_ids.append(sediment_id)
 
611
  if file_ids:
 
612
  return file_ids, sediment_ids
613
  if sediment_ids:
 
614
  return [], sediment_ids
 
615
  time.sleep(4)
616
- return [], last_sediment_ids
 
617
 
618
  def _get_file_download_url(self, file_id: str) -> str:
619
  """获取文件下载地址。"""
@@ -633,15 +533,6 @@ class OpenAIBackendAPI:
633
  data = response.json()
634
  return data.get("download_url") or data.get("url") or ""
635
 
636
- def _save_image_bytes(self, image_data: bytes) -> str:
637
- config.cleanup_old_images()
638
- file_name = f"{int(time.time())}_{new_uuid().replace('-', '')}.png"
639
- relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
640
- file_path = config.images_dir / relative_dir / file_name
641
- file_path.parent.mkdir(parents=True, exist_ok=True)
642
- file_path.write_bytes(image_data)
643
- return f"{config.base_url}/images/{relative_dir.as_posix()}/{file_name}"
644
-
645
  def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
646
  """把图片结果 id 解析成可下载 URL。"""
647
  urls = []
@@ -714,541 +605,48 @@ class OpenAIBackendAPI:
714
  })
715
  return urls
716
 
717
- def _image_response(self, urls: list[str], response_format: str) -> Dict[str, Any]:
718
- """把图片结果整理成 OpenAI `/v1/images/*` 风格结构。"""
719
- if response_format not in {"url", "b64_json"}:
720
- raise ValueError("response_format must be 'url' or 'b64_json'")
721
- data = []
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  for url in urls:
723
  response = self.session.get(url, timeout=120)
724
  ensure_ok(response, "image_download")
725
- if response_format == "b64_json":
726
- data.append({"b64_json": base64.b64encode(response.content).decode()})
727
- else:
728
- data.append({"url": self._save_image_bytes(response.content)})
729
- return {"created": int(time.time()), "data": data}
730
 
731
- def _run_image_task(self, prompt: str, model: str, size: str | None, images: Optional[list[str]] = None,
732
- response_format: str = "url") -> Dict[str, Any]:
733
- """执行图片生成或图片编辑主流程。"""
734
- if not self.access_token:
735
- raise RuntimeError("access_token is required for image endpoints")
736
- logger.debug({
737
- "event": "image_task_start",
738
- "prompt": prompt,
739
- "model": model,
740
- "size": size,
741
- "image_count": len(images or []),
742
- })
743
- references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images or [], start=1)]
744
- logger.debug({"event": "image_references_uploaded", "references": references})
745
- self._bootstrap()
746
- requirements = self._get_auth_chat_requirements()
747
- logger.debug({
748
- "event": "image_requirements_ready",
749
- "token_present": bool(requirements.token),
750
- "proof_token_present": bool(requirements.proof_token),
751
- "turnstile_token_present": bool(requirements.turnstile_token),
752
- "so_token_present": bool(requirements.so_token),
753
- "raw_finalize": requirements.raw_finalize,
754
- })
755
- final_prompt = self._build_image_prompt(prompt, size)
756
- logger.debug({"event": "image_final_prompt", "final_prompt": final_prompt})
757
- conduit_token = self._prepare_image_conversation(final_prompt, requirements, model)
758
- logger.debug({"event": "image_conduit_ready", "conduit_token_present": bool(conduit_token)})
759
- sse = self._start_image_generation(final_prompt, requirements, conduit_token, model, references)
760
- sse_result = self._parse_image_sse(sse)
761
- logger.debug({"event": "image_sse_result", "sse_result": sse_result})
762
- conversation_id = sse_result["conversation_id"]
763
- file_ids = list(sse_result["file_ids"])
764
- sediment_ids = list(sse_result["sediment_ids"])
765
- invalid_file_id_patterns = {"file_upload"}
766
- file_ids = [fid for fid in file_ids if fid not in invalid_file_id_patterns]
767
- if conversation_id and not file_ids and not sediment_ids:
768
- polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
769
- file_ids.extend([item for item in polled_file_ids if item not in file_ids])
770
- sediment_ids.extend([item for item in polled_sediment_ids if item not in sediment_ids])
771
- logger.debug({
772
- "event": "image_polled_result",
773
- "conversation_id": conversation_id,
774
- "file_ids": polled_file_ids,
775
- "sediment_ids": polled_sediment_ids,
776
- })
777
- try:
778
- conversation = self._get_conversation(conversation_id)
779
- logger.debug({
780
- "event": "image_conversation_snapshot",
781
- "conversation_id": conversation_id,
782
- "conversation": conversation,
783
- })
784
- except Exception as exc:
785
- logger.debug({
786
- "event": "image_conversation_snapshot_failed",
787
- "conversation_id": conversation_id,
788
- "error": repr(exc),
789
- })
790
- logger.debug({
791
- "event": "image_resolved_ids",
792
- "conversation_id": conversation_id,
793
- "file_ids": file_ids,
794
- "sediment_ids": sediment_ids,
795
- })
796
- urls = self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
797
- logger.debug({"event": "image_final_urls", "conversation_id": conversation_id, "urls": urls})
798
- if not urls:
799
- raise RuntimeError(
800
- "no downloadable image result found; "
801
- f"conversation_id={conversation_id}, file_ids={file_ids}, sediment_ids={sediment_ids}"
802
- )
803
- return self._image_response(urls, response_format)
804
-
805
- def _build_codex_response_input(self, prompt: str, images: Optional[list[str]] = None) -> list[Dict[str, Any]]:
806
- if not images:
807
- return [{"role": "user", "content": prompt}]
808
- content = [{"type": "input_text", "text": prompt}]
809
- for image in images:
810
- content.append({"type": "input_image", "image_url": self._image_to_data_url(image)})
811
- return [{"role": "user", "content": content}]
812
-
813
- def _collect_codex_events(self, prompt: str, model: str = CODEX_IMAGE_MODEL,
814
- images: Optional[list[str]] = None) -> list[Dict[str, Any]]:
815
- events = list(self.responses(
816
- input=self._build_codex_response_input(prompt, images),
817
- model=model,
818
- stream=True,
819
- ))
820
- logger.debug({"event": "codex_responses_events_collected", "event_count": len(events)})
821
- return events
822
-
823
- def _codex_image_response(self, events: list[Dict[str, Any]], response_format: str) -> Dict[str, Any]:
824
- if response_format not in {"url", "b64_json"}:
825
- raise ValueError("response_format must be 'url' or 'b64_json'")
826
- image_item = {}
827
- response_payload = {}
828
- for event in events:
829
- if not isinstance(event, dict):
830
- continue
831
- if event.get("type") == "response.output_item.done":
832
- item = event.get("item") or {}
833
- if item.get("type") == "image_generation_call" and item.get("result"):
834
- image_item = item
835
- elif event.get("type") == "response.completed":
836
- response_payload = event.get("response") or {}
837
- image_b64 = image_item.get("result", "")
838
- if not image_b64:
839
- raise RuntimeError("codex responses did not return image base64 result")
840
- data = []
841
- if response_format == "b64_json":
842
- data.append({"b64_json": image_b64})
843
- else:
844
- data.append({"url": self._save_image_bytes(base64.b64decode(image_b64))})
845
- return {
846
- "created": response_payload.get("created_at") or int(time.time()),
847
- "data": data,
848
- "model": response_payload.get("model") or CODEX_RESPONSE_MODEL,
849
- "usage": response_payload.get("usage"),
850
- "tool_usage": response_payload.get("tool_usage"),
851
- "response_id": response_payload.get("id"),
852
- "status": response_payload.get("status"),
853
- "revised_prompt": image_item.get("revised_prompt"),
854
- "size": image_item.get("size"),
855
- "output_format": image_item.get("output_format"),
856
- "response": response_payload,
857
- }
858
-
859
- def _run_codex_image_task(self, prompt: str, response_format: str = "url",
860
- images: Optional[list[str]] = None) -> Dict[str, Any]:
861
- logger.debug({"event": "codex_image_task_start", "prompt": prompt, "image_count": len(images or [])})
862
- events = self._collect_codex_events(prompt=prompt, model=CODEX_IMAGE_MODEL, images=images)
863
- result = self._codex_image_response(events, response_format)
864
- logger.debug({
865
- "event": "codex_image_task_done",
866
- "response_id": result.get("response_id"),
867
- "status": result.get("status"),
868
- "usage": result.get("usage"),
869
- "tool_usage": result.get("tool_usage"),
870
- })
871
- return result
872
-
873
- def _extract_text_from_events(self, events: list[Dict[str, Any]]) -> str:
874
- """从 SSE 事件中提取最终 assistant 文本。"""
875
- for event in reversed(events):
876
- message = event.get("message") or {}
877
- if (message.get("author") or {}).get("role") != "assistant":
878
- continue
879
- text = self._text_from_message(message)
880
- if text.strip():
881
- return text
882
- return ""
883
-
884
- @staticmethod
885
- def _strip_history_prefix(text: str, history_text: str) -> str:
886
- history_text = str(history_text or "")
887
- text = str(text or "")
888
- if history_text and text.startswith(history_text):
889
- return text[len(history_text):]
890
- return text
891
-
892
- def _normalize_assistant_text(self, text: str, history_text: str) -> str:
893
- return self._strip_history_prefix(text, history_text)
894
-
895
- def _text_from_message(self, message: Dict[str, Any]) -> str:
896
- """从单条 message 结构中提取文本。"""
897
- content = message.get("content") or {}
898
- parts = content.get("parts") or []
899
- if not isinstance(parts, list):
900
- return ""
901
- texts = [part for part in parts if isinstance(part, str)]
902
- if texts:
903
- return "".join(texts)
904
- return ""
905
-
906
- @staticmethod
907
- def _append_unique(values: list[str], candidates: list[str]) -> None:
908
- for candidate in candidates:
909
- if candidate and candidate not in values:
910
- values.append(candidate)
911
-
912
- @staticmethod
913
- def _extract_image_stream_ids(payload: str) -> tuple[list[str], list[str]]:
914
- file_ids = re.findall(r"(file[-_][A-Za-z0-9]+)", payload)
915
- sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
916
- return file_ids, sediment_ids
917
-
918
- @staticmethod
919
- def _extract_image_stream_conversation_id(payload: str) -> str:
920
- match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
921
- return match.group(1) if match else ""
922
-
923
- def _next_image_stream_text(self, event: Dict[str, Any], current_text: str) -> str:
924
- for candidate in (event, event.get("v")):
925
- if not isinstance(candidate, dict):
926
- continue
927
- message = candidate.get("message")
928
- if not isinstance(message, dict):
929
- continue
930
- role = str((message.get("author") or {}).get("role") or "").strip().lower()
931
- if role == "user":
932
- continue
933
- text = self._text_from_message(message)
934
- if text:
935
- return text
936
- return self._apply_text_patch(event, current_text)
937
-
938
- def stream_image_chat_completions(
939
- self,
940
- prompt: str,
941
- model: str = "gpt-image-2",
942
- size: str | None = None,
943
- images: Optional[list[str]] = None,
944
- ) -> Iterator[Dict[str, Any]]:
945
- if not self.access_token:
946
- raise RuntimeError("access_token is required for image endpoints")
947
-
948
- completion_id = f"chatcmpl-{new_uuid()}"
949
- created = int(time.time())
950
- current_text = ""
951
- sent_role = False
952
- conversation_id = ""
953
- file_ids: list[str] = []
954
- sediment_ids: list[str] = []
955
-
956
- references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images or [], start=1)]
957
  self._bootstrap()
958
- requirements = self._get_auth_chat_requirements()
959
- final_prompt = self._build_image_prompt(prompt, size)
960
- conduit_token = self._prepare_image_conversation(final_prompt, requirements, model)
961
- sse = self._start_image_generation(final_prompt, requirements, conduit_token, model, references)
962
- try:
963
- for raw_line in sse.iter_lines():
964
- if not raw_line:
965
- continue
966
- line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
967
- if not line.startswith("data:"):
968
- continue
969
- payload = line[5:].strip()
970
- if not payload:
971
- continue
972
- if payload == "[DONE]":
973
- break
974
-
975
- if not conversation_id:
976
- conversation_id = self._extract_image_stream_conversation_id(payload)
977
- new_file_ids, new_sediment_ids = self._extract_image_stream_ids(payload)
978
- self._append_unique(file_ids, new_file_ids)
979
- self._append_unique(sediment_ids, new_sediment_ids)
980
-
981
- try:
982
- event = json.loads(payload)
983
- except json.JSONDecodeError:
984
- continue
985
- if not isinstance(event, dict):
986
- continue
987
-
988
- conversation_id = str(event.get("conversation_id") or conversation_id)
989
- value = event.get("v")
990
- if isinstance(value, dict):
991
- conversation_id = str(value.get("conversation_id") or conversation_id)
992
-
993
- next_text = self._next_image_stream_text(event, current_text)
994
- if next_text == current_text:
995
- yield {
996
- "id": completion_id,
997
- "object": "chat.completion.chunk",
998
- "created": created,
999
- "model": model,
1000
- "choices": [{
1001
- "index": 0,
1002
- "delta": {},
1003
- "finish_reason": None,
1004
- }],
1005
- "upstream_event": event,
1006
- }
1007
- continue
1008
-
1009
- delta = next_text[len(current_text):] if next_text.startswith(current_text) else next_text
1010
- current_text = next_text
1011
- if not sent_role:
1012
- sent_role = True
1013
- yield {
1014
- "id": completion_id,
1015
- "object": "chat.completion.chunk",
1016
- "created": created,
1017
- "model": model,
1018
- "choices": [{
1019
- "index": 0,
1020
- "delta": {"role": "assistant", "content": delta},
1021
- "finish_reason": None,
1022
- }],
1023
- "upstream_event": event,
1024
- }
1025
- continue
1026
-
1027
- yield {
1028
- "id": completion_id,
1029
- "object": "chat.completion.chunk",
1030
- "created": created,
1031
- "model": model,
1032
- "choices": [{
1033
- "index": 0,
1034
- "delta": {"content": delta},
1035
- "finish_reason": None,
1036
- }],
1037
- "upstream_event": event,
1038
- }
1039
- finally:
1040
- sse.close()
1041
-
1042
- invalid_file_id_patterns = {"file_upload"}
1043
- file_ids = [fid for fid in file_ids if fid not in invalid_file_id_patterns]
1044
- if conversation_id and not file_ids and not sediment_ids:
1045
- polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
1046
- self._append_unique(file_ids, polled_file_ids)
1047
- self._append_unique(sediment_ids, polled_sediment_ids)
1048
-
1049
- urls = self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1050
- if not urls:
1051
- raise RuntimeError(
1052
- "no downloadable image result found; "
1053
- f"conversation_id={conversation_id}, file_ids={file_ids}, sediment_ids={sediment_ids}"
1054
- )
1055
-
1056
- image_content = build_chat_image_markdown_content(self._image_response(urls, "b64_json"))
1057
- if not sent_role:
1058
- sent_role = True
1059
- yield {
1060
- "id": completion_id,
1061
- "object": "chat.completion.chunk",
1062
- "created": created,
1063
- "model": model,
1064
- "choices": [{
1065
- "index": 0,
1066
- "delta": {"role": "assistant", "content": image_content},
1067
- "finish_reason": None,
1068
- }],
1069
- }
1070
- else:
1071
- yield {
1072
- "id": completion_id,
1073
- "object": "chat.completion.chunk",
1074
- "created": created,
1075
- "model": model,
1076
- "choices": [{
1077
- "index": 0,
1078
- "delta": {"content": image_content},
1079
- "finish_reason": None,
1080
- }],
1081
- }
1082
- yield {
1083
- "id": completion_id,
1084
- "object": "chat.completion.chunk",
1085
- "created": created,
1086
- "model": model,
1087
- "choices": [{
1088
- "index": 0,
1089
- "delta": {},
1090
- "finish_reason": "stop",
1091
- }],
1092
- }
1093
-
1094
- def _apply_text_patch(self, event: Dict[str, Any], current_text: str, history_text: str = "") -> str:
1095
- """从 patch 事件里恢复最新文本。"""
1096
- operations = event.get("v")
1097
- if not isinstance(operations, list):
1098
- return current_text
1099
- text = current_text
1100
- for item in operations:
1101
- if not isinstance(item, dict):
1102
- continue
1103
- if item.get("p") != "/message/content/parts/0":
1104
- continue
1105
- if item.get("o") == "append":
1106
- text += str(item.get("v", ""))
1107
- if item.get("o") == "replace":
1108
- text = self._normalize_assistant_text(str(item.get("v", "")), history_text)
1109
- return text
1110
-
1111
- def _next_assistant_text(self, event: Dict[str, Any], current_text: str, history_text: str = "") -> str:
1112
- """从 SSE 事件中推导当前 assistant 全量文本。"""
1113
- message = event.get("message")
1114
- if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
1115
- text = self._text_from_message(message)
1116
- if text:
1117
- return self._normalize_assistant_text(text, history_text)
1118
-
1119
- value = event.get("v")
1120
- if isinstance(value, dict):
1121
- message = value.get("message")
1122
- if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
1123
- text = self._text_from_message(message)
1124
- if text:
1125
- return self._normalize_assistant_text(text, history_text)
1126
-
1127
- return self._apply_text_patch(event, current_text, history_text)
1128
-
1129
- def _encoding_for_model(self, model: str):
1130
- """按模型选择 tokenizer,失败时回退到通用编码。"""
1131
- try:
1132
- return tiktoken.encoding_for_model(model)
1133
- except KeyError:
1134
- try:
1135
- return tiktoken.get_encoding("o200k_base")
1136
- except KeyError:
1137
- return tiktoken.get_encoding("cl100k_base")
1138
-
1139
- def _count_message_tokens(self, messages: list[Dict[str, Any]], model: str) -> int:
1140
- """估算标准 chat messages 的 token 数。"""
1141
- encoding = self._encoding_for_model(model)
1142
- tokens_per_message = 3
1143
- total = 0
1144
- for message in messages:
1145
- total += tokens_per_message
1146
- for key, value in message.items():
1147
- if not isinstance(value, str):
1148
- continue
1149
- total += len(encoding.encode(value))
1150
- if key == "name":
1151
- total += 1
1152
- return total + 3
1153
-
1154
- def _count_text_tokens(self, text: str, model: str) -> int:
1155
- """估算单段文本的 token 数。"""
1156
- return len(self._encoding_for_model(model).encode(text))
1157
-
1158
- def _extract_message_text(self, content: Any) -> str:
1159
- """从 OpenAI/Anthropic 风格的 content 字段里提取纯文本。"""
1160
- if isinstance(content, str):
1161
- return content
1162
- if isinstance(content, list):
1163
- parts = []
1164
- for item in content:
1165
- if isinstance(item, str):
1166
- parts.append(item)
1167
- continue
1168
- if not isinstance(item, dict):
1169
- raise RuntimeError("only string or text content blocks are supported")
1170
- block_type = item.get("type")
1171
- if block_type in {"text", "input_text", "output_text"}:
1172
- parts.append(str(item.get("text", "")))
1173
- continue
1174
- raise RuntimeError(f"unsupported content block type: {block_type}")
1175
- return "".join(parts)
1176
- if content is None:
1177
- return ""
1178
- raise RuntimeError("only string or text content blocks are supported")
1179
-
1180
- def _normalize_messages(self, messages: list[Dict[str, Any]], system: Any = None) -> list[Dict[str, Any]]:
1181
- """把 OpenAI/Anthropic 风格的消息统一整理成标准 chat messages。"""
1182
- normalized = []
1183
- if system is not None:
1184
- system_text = self._extract_message_text(system)
1185
- if system_text:
1186
- normalized.append({"role": "system", "content": system_text})
1187
-
1188
- for item in messages:
1189
- normalized.append({
1190
- "role": item.get("role", "user"),
1191
- "content": self._extract_message_text(item.get("content", "")),
1192
- })
1193
- return normalized
1194
-
1195
- def _assistant_history_text(self, messages: list[Dict[str, Any]]) -> str:
1196
- """获取输入历史里所有 assistant 文本拼接结果。"""
1197
- parts = []
1198
- for message in messages:
1199
- if message.get("role") != "assistant":
1200
- continue
1201
- content = message.get("content", "")
1202
- if isinstance(content, str) and content:
1203
- parts.append(content)
1204
- return "".join(parts)
1205
-
1206
- def _assistant_history_messages(self, messages: list[Dict[str, Any]]) -> list[str]:
1207
- parts = []
1208
- for message in messages:
1209
- if message.get("role") != "assistant":
1210
- continue
1211
- content = message.get("content", "")
1212
- if isinstance(content, str) and content:
1213
- parts.append(content)
1214
- return parts
1215
-
1216
- def _event_assistant_text(self, event: Dict[str, Any], history_text: str = "") -> str:
1217
- message = event.get("message")
1218
- if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
1219
- return self._normalize_assistant_text(self._text_from_message(message), history_text)
1220
-
1221
- value = event.get("v")
1222
- if isinstance(value, dict):
1223
- message = value.get("message")
1224
- if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
1225
- return self._normalize_assistant_text(self._text_from_message(message), history_text)
1226
-
1227
- return ""
1228
-
1229
- def _last_event(self, events: list[Dict[str, Any]]) -> Dict[str, Any]:
1230
- """返回最后一个非终止事件,方便排查问题。"""
1231
- for event in reversed(events):
1232
- if not event.get("done"):
1233
- return event
1234
- return {}
1235
-
1236
- def _conversation_payload_debug(self, payload: Dict[str, Any]) -> Dict[str, Any]:
1237
- raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
1238
- messages = payload.get("messages") if isinstance(payload.get("messages"), list) else []
1239
- details = []
1240
- for item in messages:
1241
- content = item.get("content") if isinstance(item, dict) else {}
1242
- parts = content.get("parts") if isinstance(content, dict) else []
1243
- text = "\n".join(str(part) for part in parts) if isinstance(parts, list) else ""
1244
- details.append({"role": (item.get("author") or {}).get("role") if isinstance(item, dict) and isinstance(item.get("author"), dict) else "", "chars": len(text), "preview": text[:120]})
1245
- return {"bytes": len(raw.encode("utf-8")), "chars": len(raw), "message_count": len(messages), "messages": details}
1246
-
1247
- def _stream_events(self, path: str, requirements: ChatRequirements, payload: Dict[str, Any]) -> Iterator[
1248
- Dict[str, Any]]:
1249
- """向 conversation 接口发起请求,并逐条产出 SSE 事件。"""
1250
- payload_debug = self._conversation_payload_debug(payload)
1251
- logger.info({"event": "conversation_payload_debug", "path": path, **payload_debug})
1252
  response = self.session.post(
1253
  self.base_url + path,
1254
  headers=self._conversation_headers(path, requirements),
@@ -1256,10 +654,29 @@ class OpenAIBackendAPI:
1256
  timeout=300,
1257
  stream=True,
1258
  )
1259
- if response.status_code >= 400:
1260
- logger.warning({"event": "conversation_request_failed", "path": path, "status_code": response.status_code, "payload": payload_debug, "response_text": response.text[:500]})
1261
  ensure_ok(response, path)
1262
- yield from parse_sse_lines(response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1263
 
1264
  def _bootstrap(self) -> None:
1265
  """预热首页,并提取 PoW 相关脚本引用。"""
@@ -1273,13 +690,11 @@ class OpenAIBackendAPI:
1273
  if not self.pow_script_sources:
1274
  self.pow_script_sources = [DEFAULT_POW_SCRIPT]
1275
 
1276
- def _get_chat_requirements(self, authenticated: bool) -> ChatRequirements:
1277
  """获取当前模式对话所需的 sentinel token。"""
1278
- if authenticated and not self.access_token:
1279
- raise RuntimeError("access_token is required for auth chat")
1280
- path = "/backend-api/sentinel/chat-requirements" if authenticated else "/backend-anon/sentinel/chat-requirements"
1281
- context = "auth_chat_requirements" if authenticated else "noauth_chat_requirements"
1282
- body = {"p": self._build_requirements_token()}
1283
  response = self.session.post(
1284
  self.base_url + path,
1285
  headers=self._headers(path, {"Content-Type": "application/json"}),
@@ -1287,292 +702,48 @@ class OpenAIBackendAPI:
1287
  timeout=30,
1288
  )
1289
  ensure_ok(response, context)
1290
- requirements = self._build_requirements(response.json(), "" if authenticated else body["p"])
1291
  if not requirements.token:
1292
- message = "missing auth chat requirements token" if authenticated else "missing chat requirements token"
1293
  raise RuntimeError(f"{message}: {requirements.raw_finalize}")
1294
  return requirements
1295
 
1296
- def _get_auth_chat_requirements(self) -> ChatRequirements:
1297
- return self._get_chat_requirements(authenticated=True)
1298
-
1299
- def _get_anon_chat_requirements(self) -> ChatRequirements:
1300
- return self._get_chat_requirements(authenticated=False)
1301
 
1302
- def _get_models_raw(self, authenticated: bool) -> Dict[str, Any]:
1303
- """获取当前模式模型列表原始响应。"""
1304
- if authenticated and not self.access_token:
1305
- raise RuntimeError("access_token is required for auth models")
1306
  self._bootstrap()
1307
- path = "/backend-api/models?history_and_training_disabled=false" if authenticated else (
1308
  "/backend-anon/models?iim=false&is_gizmo=false"
1309
  )
1310
- route = "/backend-api/models" if authenticated else "/backend-anon/models"
1311
- context = "auth_models" if authenticated else "anon_models"
1312
  response = self.session.get(
1313
  self.base_url + path,
1314
  headers=self._headers(route),
1315
  timeout=30,
1316
  )
1317
  ensure_ok(response, context)
1318
- return response.json()
1319
-
1320
- def _chat_target(self) -> tuple[str, str]:
1321
- if self.access_token:
1322
- return "/backend-api/conversation", "Asia/Shanghai"
1323
- return "/backend-anon/conversation", "America/Los_Angeles"
1324
-
1325
- def _complete_chat(self, messages: list[Dict[str, Any]], model: str) -> Dict[str, Any]:
1326
- self._bootstrap()
1327
- requirements = self._get_chat_requirements(authenticated=bool(self.access_token))
1328
- path, timezone = self._chat_target()
1329
- events = list(self._stream_events(path, requirements, self._conversation_payload(messages, model, timezone)))
1330
- history_assistant_text = self._assistant_history_text(messages)
1331
- return {
1332
- "requirements": requirements,
1333
- "prepare": {},
1334
- "events": events,
1335
- "last_event": self._last_event(events),
1336
- "text": self._strip_history_prefix(self._extract_text_from_events(events), history_assistant_text),
1337
- }
1338
-
1339
- def list_models(self) -> Dict[str, Any]:
1340
- """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
1341
- return self._normalize_models(self._get_models_raw(authenticated=bool(self.access_token)))
1342
-
1343
- def images_generations(self, prompt: str, model: str = "gpt-image-2", size: str | None = None,
1344
- response_format: str = "url") -> Dict[str, Any]:
1345
- """返回 OpenAI `/v1/images/generations` 风格结果。"""
1346
- if self._is_codex_image_model(model):
1347
- return self._run_codex_image_task(prompt, response_format=response_format)
1348
- return self._run_image_task(prompt, model, size, response_format=response_format)
1349
-
1350
- def images_edits(self, image: str | list[str], prompt: str, model: str = "gpt-image-2", size: str | None = None,
1351
- response_format: str = "url") -> Dict[str, Any]:
1352
- """返回 OpenAI `/v1/images/edits` 风格结果。"""
1353
- images = [image] if isinstance(image, str) else image
1354
- if not images:
1355
- raise ValueError("image is required for image edits")
1356
- if self._is_codex_image_model(model):
1357
- return self._run_codex_image_task(prompt, response_format=response_format, images=images)
1358
- return self._run_image_task(prompt, model, size, images=images, response_format=response_format)
1359
-
1360
- def _chat_completion_response(self, model: str, messages: list[Dict[str, Any]], text: str) -> Dict[str, Any]:
1361
- """把对话结果整理成 OpenAI `/v1/chat/completions` 风格结构。"""
1362
- prompt_tokens = self._count_message_tokens(messages, model)
1363
- completion_tokens = self._count_text_tokens(text, model)
1364
- return {
1365
- "id": f"chatcmpl-{new_uuid()}",
1366
- "object": "chat.completion",
1367
- "created": int(time.time()),
1368
- "model": model,
1369
- "choices": [{
1370
- "index": 0,
1371
- "message": {
1372
- "role": "assistant",
1373
- "content": text,
1374
- },
1375
- "finish_reason": "stop",
1376
- }],
1377
- "usage": {
1378
- "prompt_tokens": prompt_tokens,
1379
- "completion_tokens": completion_tokens,
1380
- "total_tokens": prompt_tokens + completion_tokens,
1381
- },
1382
- }
1383
-
1384
- def _stream_chat_completions(self, messages: list[Dict[str, Any]], model: str = "auto") -> Iterator[Dict[str, Any]]:
1385
- """返回 OpenAI `/v1/chat/completions` 风格的流式 chunk。"""
1386
- completion_id = f"chatcmpl-{new_uuid()}"
1387
- created = int(time.time())
1388
- history_assistant_text = self._assistant_history_text(messages)
1389
- history_assistant_messages = self._assistant_history_messages(messages)
1390
- history_assistant_index = 0
1391
- current_text = ""
1392
- sent_role = False
1393
- self._bootstrap()
1394
- requirements = self._get_chat_requirements(authenticated=bool(self.access_token))
1395
- path, timezone = self._chat_target()
1396
- payload = self._conversation_payload(messages, model, timezone)
1397
-
1398
- for event in self._stream_events(path, requirements, payload):
1399
- if event.get("done"):
1400
- break
1401
- event_assistant_text = self._event_assistant_text(event, history_assistant_text)
1402
- if (
1403
- history_assistant_index < len(history_assistant_messages)
1404
- and event_assistant_text
1405
- and event_assistant_text == history_assistant_messages[history_assistant_index]
1406
- ):
1407
- history_assistant_index += 1
1408
- current_text = ""
1409
- continue
1410
- next_text = self._next_assistant_text(event, current_text, history_assistant_text)
1411
- if next_text == current_text:
1412
- continue
1413
- delta = next_text[len(current_text):] if next_text.startswith(current_text) else next_text
1414
- current_text = next_text
1415
- if not sent_role:
1416
- sent_role = True
1417
- yield {
1418
- "id": completion_id,
1419
- "object": "chat.completion.chunk",
1420
- "created": created,
1421
- "model": model,
1422
- "choices": [{
1423
- "index": 0,
1424
- "delta": {"role": "assistant", "content": delta},
1425
- "finish_reason": None,
1426
- }],
1427
- }
1428
- continue
1429
- yield {
1430
- "id": completion_id,
1431
- "object": "chat.completion.chunk",
1432
- "created": created,
1433
- "model": model,
1434
- "choices": [{
1435
- "index": 0,
1436
- "delta": {"content": delta},
1437
- "finish_reason": None,
1438
- }],
1439
- }
1440
-
1441
- if not sent_role:
1442
- yield {
1443
- "id": completion_id,
1444
- "object": "chat.completion.chunk",
1445
- "created": created,
1446
- "model": model,
1447
- "choices": [{
1448
- "index": 0,
1449
- "delta": {"role": "assistant", "content": ""},
1450
- "finish_reason": None,
1451
- }],
1452
- }
1453
- yield {
1454
- "id": completion_id,
1455
- "object": "chat.completion.chunk",
1456
- "created": created,
1457
- "model": model,
1458
- "choices": [{
1459
- "index": 0,
1460
- "delta": {},
1461
- "finish_reason": "stop",
1462
- }],
1463
- }
1464
-
1465
- def _anthropic_message_response(self, model: str, messages: list[Dict[str, Any]], text: str, tools: Any = None) -> Dict[str, Any]:
1466
- """把对话结果整理成 Anthropic `/v1/messages` 风格结构。"""
1467
- return message_response(model, text, self._count_message_tokens(messages, model), self._count_text_tokens(text, model), tools)
1468
-
1469
- def _stream_anthropic_messages(self, messages: list[Dict[str, Any]], model: str = "auto", tools: Any = None) -> Iterator[
1470
- Dict[str, Any]]:
1471
- """返回 Anthropic `/v1/messages` 风格的流式事件。"""
1472
- yield from stream_events(self._stream_chat_completions(messages, model), model, self._count_message_tokens(messages, model), lambda text: self._count_text_tokens(text, model), tools)
1473
-
1474
- def _iter_response_events(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
1475
- """按 Responses 接口事件格式直通输出,不额外包装标准事件。"""
1476
- for raw_line in response.iter_lines():
1477
- if not raw_line:
1478
- continue
1479
- line = raw_line.decode("utf-8", errors="ignore")
1480
- if not line.startswith("data:"):
1481
  continue
1482
- payload = line[5:].strip()
1483
- if not payload or payload == "[DONE]":
1484
  continue
1485
- yield json.loads(payload)
1486
-
1487
- def responses(
1488
- self,
1489
- input: str | list[Dict[str, Any]],
1490
- model: str = CODEX_RESPONSE_MODEL,
1491
- tools: Optional[list[Dict[str, Any]]] = None,
1492
- instructions: str = "you are a helpful assistant",
1493
- tool_choice: str = "auto",
1494
- stream: bool = False,
1495
- store: bool = False,
1496
- ) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
1497
- """返回 `/v1/responses` 风格结果,底层走 `/backend-api/codex/responses`。"""
1498
- if not self.access_token:
1499
- raise RuntimeError("access_token is required for responses")
1500
- path = "/backend-api/codex/responses"
1501
- token_info = self._get_token_info()
1502
- input_items = [{"role": "user", "content": input}] if isinstance(input, str) else input
1503
- effective_model = CODEX_RESPONSE_MODEL if self._is_codex_image_model(model) else model
1504
- effective_tools = tools
1505
- if self._is_codex_image_model(model) and not effective_tools:
1506
- effective_tools = [{"type": "image_generation", "output_format": "png"}]
1507
- payload = {
1508
- "model": effective_model,
1509
- "input": input_items,
1510
- "tools": effective_tools or [],
1511
- "instructions": instructions,
1512
- "tool_choice": tool_choice,
1513
- "stream": stream,
1514
- "store": store,
1515
- }
1516
- logger.debug({
1517
- "event": "responses_start",
1518
- "model": model,
1519
- "effective_model": effective_model,
1520
- "stream": stream,
1521
- "input_count": len(input_items),
1522
- "tool_count": len(payload["tools"]),
1523
- "token_info": token_info,
1524
- })
1525
- headers = {
1526
- "Authorization": f"Bearer {self.access_token}",
1527
- "user-agent": (
1528
- "codex-tui/0.122.0 (Manjaro 26.1.0-pre; x86_64) "
1529
- "vscode/3.0.12 (codex-tui; 0.122.0)"
1530
- ),
1531
- "version": "0.122.0",
1532
- "originator": "codex_cli_rs",
1533
- "session_id": "test-session",
1534
- "accept": "text/event-stream" if stream else "application/json",
1535
- "Content-Type": "application/json",
1536
- }
1537
- if token_info.get("chatgpt_account_id"):
1538
- headers["chatgpt-account-id"] = token_info["chatgpt_account_id"]
1539
- response = self.session.post(
1540
- self.base_url + path,
1541
- headers=headers,
1542
- json=payload,
1543
- timeout=300,
1544
- stream=stream,
1545
- )
1546
- if response.status_code >= 400:
1547
- logger.debug({
1548
- "event": "responses_failed",
1549
- "status_code": response.status_code,
1550
- "headers": dict(response.headers),
1551
- "body": response.text,
1552
  })
1553
- ensure_ok(response, path)
1554
- if stream:
1555
- return self._iter_response_events(response)
1556
- return response.json()
1557
-
1558
- def chat_completions(
1559
- self,
1560
- messages: list[Dict[str, Any]],
1561
- model: str = "auto",
1562
- stream: bool = False,
1563
- ) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
1564
- """返回 OpenAI `/v1/chat/completions` 风格结果,支持 stream 模式。"""
1565
- normalized_messages = self._normalize_messages(messages)
1566
- if stream:
1567
- return self._stream_chat_completions(normalized_messages, model)
1568
- result = self._complete_chat(normalized_messages, model)
1569
- return self._chat_completion_response(model, normalized_messages, result["text"])
1570
-
1571
- def messages(self, messages: list[Dict[str, Any]], model: str = "auto", stream: bool = False,
1572
- system: Any = None, tools: Any = None) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
1573
- """返回 Anthropic `/v1/messages` 风格结果,支持 stream 模式。"""
1574
- normalized_messages = self._normalize_messages(messages, system)
1575
- if stream:
1576
- return self._stream_anthropic_messages(normalized_messages, model, tools)
1577
- result = self._complete_chat(normalized_messages, model)
1578
- return self._anthropic_message_response(model, normalized_messages, result["text"], tools)
 
1
  import base64
 
2
  import os
3
  import re
4
  import time
 
7
  from pathlib import Path
8
  from typing import Any, Dict, Iterator, Optional
9
 
 
10
  from curl_cffi import requests
11
  from PIL import Image
12
 
13
  from services.account_service import account_service
 
 
14
  from services.proxy_service import proxy_settings
15
+ from utils.helper import ensure_ok, iter_sse_payloads, new_uuid
16
  from utils.log import logger
17
  from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
18
  from utils.turnstile import solve_turnstile_token
 
32
  DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
33
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
34
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
 
35
 
36
 
37
  class OpenAIBackendAPI:
 
42
  例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
43
  - 不传 `access_token` 时,会走未登录链路
44
  例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
45
+ - `stream_conversation()` 是底层统一流式入口
46
+ - 协议兼容转换放在 `services.protocol`
47
  """
48
 
49
  def __init__(self, access_token: str = "") -> None:
 
150
  "Upgrade-Insecure-Requests": "1",
151
  }
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
154
  """把 sentinel 响应整理成后续对话需要的 token 集合。"""
155
  if (data.get("arkose") or {}).get("required"):
 
241
  },
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  def _image_model_slug(self, model: str) -> str:
245
  """把标准图片模型名映射到底层 model slug。"""
246
  model = str(model or "").strip()
247
  if not model:
248
  return "auto"
249
+ if model == "gpt-image-2":
250
  return "gpt-5-3"
251
  if model == CODEX_IMAGE_MODEL:
252
  return model
253
  return "auto"
254
 
 
 
 
255
  def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
256
  Dict[str, str]:
257
  """构造图片链路请求头。"""
 
314
  payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
315
  return base64.b64decode(payload)
316
 
 
 
 
 
 
 
 
 
 
 
317
  def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
318
  """上传一张 base64 图片,返回底层文件元数据。"""
319
  data = self._decode_image_base64(image)
 
447
  ensure_ok(response, path)
448
  return response
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
451
  """获取完整 conversation 详情。"""
452
  path = f"/backend-api/conversation/{conversation_id}"
 
490
  def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
491
  """轮询 conversation,直到拿到图片文件 id 或超时。"""
492
  start = time.time()
493
+ attempt = 0
494
+ logger.info({"event": "image_poll_start", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
495
  while time.time() - start < timeout_secs:
496
+ attempt += 1
497
  conversation = self._get_conversation(conversation_id)
498
  file_ids, sediment_ids = [], []
499
  for record in self._extract_image_tool_records(conversation):
 
503
  for sediment_id in record["sediment_ids"]:
504
  if sediment_id not in sediment_ids:
505
  sediment_ids.append(sediment_id)
506
+ logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, "file_ids": file_ids, "sediment_ids": sediment_ids})
507
  if file_ids:
508
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids})
509
  return file_ids, sediment_ids
510
  if sediment_ids:
511
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [], "sediment_ids": sediment_ids})
512
  return [], sediment_ids
513
+ logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, "elapsed_secs": round(time.time() - start, 1)})
514
  time.sleep(4)
515
+ logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
516
+ return [], []
517
 
518
  def _get_file_download_url(self, file_id: str) -> str:
519
  """获取文件下载地址。"""
 
533
  data = response.json()
534
  return data.get("download_url") or data.get("url") or ""
535
 
 
 
 
 
 
 
 
 
 
536
  def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
537
  """把图片结果 id 解析成可下载 URL。"""
538
  urls = []
 
605
  })
606
  return urls
607
 
608
+ def resolve_conversation_image_urls(
609
+ self,
610
+ conversation_id: str,
611
+ file_ids: list[str],
612
+ sediment_ids: list[str],
613
+ poll: bool = True,
614
+ ) -> list[str]:
615
+ file_ids = [item for item in file_ids if item != "file_upload"]
616
+ sediment_ids = list(sediment_ids)
617
+ if poll and conversation_id and not file_ids and not sediment_ids:
618
+ logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
619
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
620
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
621
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
622
+ return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
623
+
624
+ def download_image_bytes(self, urls: list[str]) -> list[bytes]:
625
+ images = []
626
  for url in urls:
627
  response = self.session.get(url, timeout=120)
628
  ensure_ok(response, "image_download")
629
+ images.append(response.content)
630
+ return images
 
 
 
631
 
632
+ def stream_conversation(
633
+ self,
634
+ messages: Optional[list[Dict[str, Any]]] = None,
635
+ model: str = "auto",
636
+ prompt: str = "",
637
+ images: Optional[list[str]] = None,
638
+ system_hints: Optional[list[str]] = None,
639
+ ) -> Iterator[str]:
640
+ system_hints = system_hints or []
641
+ if "picture_v2" in system_hints:
642
+ yield from self._stream_picture_conversation(prompt, model, images or [])
643
+ return
644
+
645
+ normalized = messages or [{"role": "user", "content": prompt}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  self._bootstrap()
647
+ requirements = self._get_chat_requirements()
648
+ path, timezone = self._chat_target()
649
+ payload = self._conversation_payload(normalized, model, timezone)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  response = self.session.post(
651
  self.base_url + path,
652
  headers=self._conversation_headers(path, requirements),
 
654
  timeout=300,
655
  stream=True,
656
  )
 
 
657
  ensure_ok(response, path)
658
+ try:
659
+ yield from iter_sse_payloads(response)
660
+ finally:
661
+ response.close()
662
+
663
+ def _stream_picture_conversation(
664
+ self,
665
+ prompt: str,
666
+ model: str,
667
+ images: list[str],
668
+ ) -> Iterator[str]:
669
+ if not self.access_token:
670
+ raise RuntimeError("access_token is required for image endpoints")
671
+ references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
672
+ self._bootstrap()
673
+ requirements = self._get_chat_requirements()
674
+ conduit_token = self._prepare_image_conversation(prompt, requirements, model)
675
+ response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
676
+ try:
677
+ yield from iter_sse_payloads(response)
678
+ finally:
679
+ response.close()
680
 
681
  def _bootstrap(self) -> None:
682
  """预热首页,并提取 PoW 相关脚本引用。"""
 
690
  if not self.pow_script_sources:
691
  self.pow_script_sources = [DEFAULT_POW_SCRIPT]
692
 
693
+ def _get_chat_requirements(self) -> ChatRequirements:
694
  """获取当前模式对话所需的 sentinel token。"""
695
+ path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
696
+ context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
697
+ body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
 
 
698
  response = self.session.post(
699
  self.base_url + path,
700
  headers=self._headers(path, {"Content-Type": "application/json"}),
 
702
  timeout=30,
703
  )
704
  ensure_ok(response, context)
705
+ requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
706
  if not requirements.token:
707
+ message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
708
  raise RuntimeError(f"{message}: {requirements.raw_finalize}")
709
  return requirements
710
 
711
+ def _chat_target(self) -> tuple[str, str]:
712
+ if self.access_token:
713
+ return "/backend-api/conversation", "Asia/Shanghai"
714
+ return "/backend-anon/conversation", "America/Los_Angeles"
 
715
 
716
+ def list_models(self) -> Dict[str, Any]:
717
+ """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
 
 
718
  self._bootstrap()
719
+ path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
720
  "/backend-anon/models?iim=false&is_gizmo=false"
721
  )
722
+ route = "/backend-api/models" if self.access_token else "/backend-anon/models"
723
+ context = "auth_models" if self.access_token else "anon_models"
724
  response = self.session.get(
725
  self.base_url + path,
726
  headers=self._headers(route),
727
  timeout=30,
728
  )
729
  ensure_ok(response, context)
730
+ data = []
731
+ seen = set()
732
+ for item in response.json().get("models", []):
733
+ if not isinstance(item, dict):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  continue
735
+ slug = str(item.get("slug", "")).strip()
736
+ if not slug or slug in seen:
737
  continue
738
+ seen.add(slug)
739
+ data.append({
740
+ "id": slug,
741
+ "object": "model",
742
+ "created": int(item.get("created") or 0),
743
+ "owned_by": str(item.get("owned_by") or "chatgpt"),
744
+ "permission": [],
745
+ "root": slug,
746
+ "parent": None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  })
748
+ data.sort(key=lambda item: item["id"])
749
+ return {"object": "list", "data": data}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/protocol/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Protocol converters for OpenAI-compatible endpoints."""
2
+
services/{anthropic_protocol.py → protocol/anthropic_v1_messages.py} RENAMED
@@ -6,12 +6,26 @@ import re
6
  import time
7
  import uuid
8
  from collections.abc import Callable, Iterable, Iterator
 
9
  from typing import Any
10
 
 
 
 
 
 
11
  XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
12
  <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
13
 
14
 
 
 
 
 
 
 
 
 
15
  def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
16
  fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
17
  name = str(tool.get("name") or fn.get("name") or "").strip()
@@ -93,6 +107,16 @@ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str],
93
  return payload
94
 
95
 
 
 
 
 
 
 
 
 
 
 
96
  def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
97
  if not isinstance(messages, list):
98
  return messages
@@ -260,3 +284,23 @@ def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int =
260
  yield {"type": "content_block_start", "index": index, "content_block": start}
261
  yield {"type": "content_block_delta", "index": index, "delta": delta}
262
  yield {"type": "content_block_stop", "index": index}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
 
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
 
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,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, Iterator
11
+
12
+ import tiktoken
13
+
14
+ from services.account_service import account_service
15
+ from services.config import config
16
+ from services.openai_backend_api import OpenAIBackendAPI
17
+ from utils.helper import IMAGE_MODELS
18
+ from utils.log import logger
19
+
20
+
21
+ class ImageGenerationError(Exception):
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ status_code: int = 502,
26
+ error_type: str = "server_error",
27
+ code: str | None = "upstream_error",
28
+ param: str | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.error_type = error_type
33
+ self.code = code
34
+ self.param = param
35
+
36
+ def to_openai_error(self) -> dict[str, Any]:
37
+ return {
38
+ "error": {
39
+ "message": str(self),
40
+ "type": self.error_type,
41
+ "param": self.param,
42
+ "code": self.code,
43
+ }
44
+ }
45
+
46
+
47
+ def is_token_invalid_error(message: str) -> bool:
48
+ text = str(message or "").lower()
49
+ return (
50
+ "token_invalidated" in text
51
+ or "token_revoked" in text
52
+ or "authentication token has been invalidated" in text
53
+ or "invalidated oauth token" in text
54
+ )
55
+
56
+
57
+ def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
58
+ return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
59
+
60
+
61
+ def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
62
+ config.cleanup_old_images()
63
+ file_hash = hashlib.md5(image_data).hexdigest()
64
+ filename = f"{int(time.time())}_{file_hash}.png"
65
+ relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
66
+ file_path = config.images_dir / relative_dir / filename
67
+ file_path.parent.mkdir(parents=True, exist_ok=True)
68
+ file_path.write_bytes(image_data)
69
+ return f"{(base_url or config.base_url)}/images/{relative_dir.as_posix()}/{filename}"
70
+
71
+
72
+ def message_text(content: Any) -> str:
73
+ if isinstance(content, str):
74
+ return content
75
+ if isinstance(content, list):
76
+ parts = []
77
+ for item in content:
78
+ if isinstance(item, str):
79
+ parts.append(item)
80
+ elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
81
+ parts.append(str(item.get("text") or ""))
82
+ return "".join(parts)
83
+ return ""
84
+
85
+
86
+ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
87
+ normalized = []
88
+ system_text = message_text(system)
89
+ if system_text:
90
+ normalized.append({"role": "system", "content": system_text})
91
+ if isinstance(messages, list):
92
+ for message in messages:
93
+ if isinstance(message, dict):
94
+ normalized.append({"role": message.get("role", "user"), "content": message_text(message.get("content", ""))})
95
+ return normalized
96
+
97
+
98
+ def assistant_history_text(messages: list[dict[str, Any]]) -> str:
99
+ return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
100
+
101
+
102
+ def build_image_prompt(prompt: str, size: str | None) -> str:
103
+ if not size:
104
+ return prompt
105
+ if size not in {"1:1", "16:9", "9:16", "4:3", "3:4"}:
106
+ return f"{prompt.strip()}\n\n输出图片,宽高比为 {size}。"
107
+ hint = {
108
+ "1:1": "输出为 1:1 正方形构图,主体居中,适合正方形画幅。",
109
+ "16:9": "输出为 16:9 横屏构图,适合宽画幅展示。",
110
+ "9:16": "输出为 9:16 竖屏构图,适合竖版画幅展示。",
111
+ "4:3": "输出为 4:3 比例,兼顾宽度与高度,适合展示画面细节。",
112
+ "3:4": "输出为 3:4 比例,纵向构图,适合人物肖像或竖向场景。",
113
+ }[size]
114
+ return f"{prompt.strip()}\n\n{hint}"
115
+
116
+
117
+ def encoding_for_model(model: str):
118
+ try:
119
+ return tiktoken.encoding_for_model(model)
120
+ except KeyError:
121
+ try:
122
+ return tiktoken.get_encoding("o200k_base")
123
+ except KeyError:
124
+ return tiktoken.get_encoding("cl100k_base")
125
+
126
+
127
+ def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
128
+ encoding = encoding_for_model(model)
129
+ total = 0
130
+ for message in messages:
131
+ total += 3
132
+ for key, value in message.items():
133
+ if not isinstance(value, str):
134
+ continue
135
+ total += len(encoding.encode(value))
136
+ if key == "name":
137
+ total += 1
138
+ return total + 3
139
+
140
+
141
+ def count_text_tokens(text: str, model: str) -> int:
142
+ return len(encoding_for_model(model).encode(text))
143
+
144
+
145
+ def format_image_result(
146
+ items: list[dict[str, Any]],
147
+ prompt: str,
148
+ response_format: str,
149
+ base_url: str | None = None,
150
+ created: int | None = None,
151
+ message: str = "",
152
+ ) -> dict[str, Any]:
153
+ data: list[dict[str, Any]] = []
154
+ for item in items:
155
+ b64_json = str(item.get("b64_json") or "").strip()
156
+ if not b64_json:
157
+ continue
158
+ revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
159
+ if response_format == "b64_json":
160
+ data.append({
161
+ "b64_json": b64_json,
162
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
163
+ "revised_prompt": revised_prompt,
164
+ })
165
+ else:
166
+ data.append({
167
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
168
+ "revised_prompt": revised_prompt,
169
+ })
170
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
171
+ if message and not data:
172
+ result["message"] = message
173
+ return result
174
+
175
+
176
+ @dataclass
177
+ class ConversationRequest:
178
+ model: str = "auto"
179
+ prompt: str = ""
180
+ messages: list[dict[str, Any]] | None = None
181
+ images: list[str] | None = None
182
+ n: int = 1
183
+ size: str | None = None
184
+ response_format: str = "b64_json"
185
+ base_url: str | None = None
186
+ message_as_error: bool = False
187
+
188
+
189
+ @dataclass
190
+ class ConversationState:
191
+ text: str = ""
192
+ conversation_id: str = ""
193
+ file_ids: list[str] = field(default_factory=list)
194
+ sediment_ids: list[str] = field(default_factory=list)
195
+ blocked: bool = False
196
+ tool_invoked: bool | None = None
197
+ turn_use_case: str = ""
198
+
199
+
200
+ @dataclass
201
+ class ImageOutput:
202
+ kind: str
203
+ model: str
204
+ index: int
205
+ total: int
206
+ created: int = field(default_factory=lambda: int(time.time()))
207
+ text: str = ""
208
+ upstream_event_type: str = ""
209
+ data: list[dict[str, Any]] = field(default_factory=list)
210
+
211
+ def to_chunk(self) -> dict[str, Any]:
212
+ chunk: dict[str, Any] = {
213
+ "object": "image.generation.chunk",
214
+ "created": self.created,
215
+ "model": self.model,
216
+ "index": self.index,
217
+ "total": self.total,
218
+ "progress_text": self.text,
219
+ "upstream_event_type": self.upstream_event_type,
220
+ "data": [],
221
+ }
222
+ if self.kind == "message":
223
+ chunk.update({
224
+ "object": "image.generation.message",
225
+ "message": self.text,
226
+ })
227
+ chunk.pop("progress_text", None)
228
+ chunk.pop("upstream_event_type", None)
229
+ elif self.kind == "result":
230
+ chunk.update({
231
+ "object": "image.generation.result",
232
+ "data": self.data,
233
+ })
234
+ chunk.pop("progress_text", None)
235
+ chunk.pop("upstream_event_type", None)
236
+ return chunk
237
+
238
+
239
+ def assistant_message_text(message: dict[str, Any]) -> str:
240
+ content = message.get("content") or {}
241
+ parts = content.get("parts") or []
242
+ if not isinstance(parts, list):
243
+ return ""
244
+ return "".join(part for part in parts if isinstance(part, str))
245
+
246
+
247
+ def strip_history(text: str, history_text: str = "") -> str:
248
+ text = str(text or "")
249
+ history_text = str(history_text or "")
250
+ return text[len(history_text):] if history_text and text.startswith(history_text) else text
251
+
252
+
253
+ def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
254
+ for candidate in (event, event.get("v")):
255
+ if not isinstance(candidate, dict):
256
+ continue
257
+ message = candidate.get("message")
258
+ if not isinstance(message, dict):
259
+ continue
260
+ role = str((message.get("author") or {}).get("role") or "").strip().lower()
261
+ if role != "assistant":
262
+ continue
263
+ text = assistant_message_text(message)
264
+ if text:
265
+ return strip_history(text, history_text)
266
+ return apply_text_patch(event, current_text, history_text)
267
+
268
+
269
+ def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
270
+ if event.get("p") == "/message/content/parts/0":
271
+ return apply_patch_op(event, current_text, history_text)
272
+
273
+ operations = event.get("v")
274
+ if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
275
+ return current_text + operations
276
+
277
+ if event.get("o") == "patch" and isinstance(operations, list):
278
+ text = current_text
279
+ for item in operations:
280
+ if isinstance(item, dict):
281
+ text = apply_text_patch(item, text, history_text)
282
+ return text
283
+
284
+ if not isinstance(operations, list):
285
+ return current_text
286
+
287
+ text = current_text
288
+ for item in operations:
289
+ if isinstance(item, dict):
290
+ text = apply_text_patch(item, text, history_text)
291
+ return text
292
+
293
+
294
+ def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
295
+ op = operation.get("o")
296
+ value = str(operation.get("v") or "")
297
+ if op == "append":
298
+ return current_text + value
299
+ if op == "replace":
300
+ return strip_history(value, history_text)
301
+ return current_text
302
+
303
+
304
+ def add_unique(values: list[str], candidates: list[str]) -> None:
305
+ for candidate in candidates:
306
+ if candidate and candidate not in values:
307
+ values.append(candidate)
308
+
309
+
310
+ def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
311
+ conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
312
+ conversation_id = conversation_match.group(1) if conversation_match else ""
313
+ file_ids = re.findall(r"(file[-_][A-Za-z0-9]+)", payload)
314
+ sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
315
+ return conversation_id, file_ids, sediment_ids
316
+
317
+
318
+ def is_image_tool_event(event: dict[str, Any]) -> bool:
319
+ value = event.get("v")
320
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
321
+ if not isinstance(message, dict):
322
+ return False
323
+ metadata = message.get("metadata") or {}
324
+ author = message.get("author") or {}
325
+ return author.get("role") == "tool" and metadata.get("async_task_type") == "image_gen"
326
+
327
+
328
+ def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
329
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
330
+ if conversation_id and not state.conversation_id:
331
+ state.conversation_id = conversation_id
332
+ if isinstance(event, dict) and is_image_tool_event(event):
333
+ add_unique(state.file_ids, file_ids)
334
+ add_unique(state.sediment_ids, sediment_ids)
335
+ if not isinstance(event, dict):
336
+ return
337
+ state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
338
+ value = event.get("v")
339
+ if isinstance(value, dict):
340
+ state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
341
+ if event.get("type") == "moderation":
342
+ moderation = event.get("moderation_response")
343
+ if isinstance(moderation, dict) and moderation.get("blocked") is True:
344
+ state.blocked = True
345
+ if event.get("type") == "server_ste_metadata":
346
+ metadata = event.get("metadata")
347
+ if isinstance(metadata, dict):
348
+ if isinstance(metadata.get("tool_invoked"), bool):
349
+ state.tool_invoked = metadata["tool_invoked"]
350
+ state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
351
+
352
+
353
+ def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
354
+ return {
355
+ "type": event_type,
356
+ "text": state.text,
357
+ "conversation_id": state.conversation_id,
358
+ "file_ids": list(state.file_ids),
359
+ "sediment_ids": list(state.sediment_ids),
360
+ "blocked": state.blocked,
361
+ "tool_invoked": state.tool_invoked,
362
+ "turn_use_case": state.turn_use_case,
363
+ **extra,
364
+ }
365
+
366
+
367
+ def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "") -> Iterator[dict[str, Any]]:
368
+ state = ConversationState()
369
+ for payload in payloads:
370
+ # print(f"[upstream_sse] {payload}", flush=True)
371
+ if not payload:
372
+ continue
373
+ if payload == "[DONE]":
374
+ yield conversation_base_event("conversation.done", state, done=True)
375
+ break
376
+ try:
377
+ event = json.loads(payload)
378
+ except json.JSONDecodeError:
379
+ update_conversation_state(state, payload)
380
+ yield conversation_base_event("conversation.raw", state, payload=payload)
381
+ continue
382
+ if not isinstance(event, dict):
383
+ yield conversation_base_event("conversation.event", state, raw=event)
384
+ continue
385
+ update_conversation_state(state, payload, event)
386
+ next_text = assistant_text(event, state.text, history_text)
387
+ if next_text != state.text:
388
+ delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
389
+ state.text = next_text
390
+ yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
391
+ continue
392
+ yield conversation_base_event("conversation.event", state, raw=event)
393
+
394
+
395
+ def conversation_events(
396
+ backend: OpenAIBackendAPI,
397
+ messages: list[dict[str, Any]] | None = None,
398
+ model: str = "auto",
399
+ prompt: str = "",
400
+ images: list[str] | None = None,
401
+ size: str | None = None,
402
+ ) -> Iterator[dict[str, Any]]:
403
+ normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
404
+ image_model = str(model or "").strip() in IMAGE_MODELS
405
+ history_text = "" if image_model else assistant_history_text(normalized)
406
+ final_prompt = build_image_prompt(prompt, size) if image_model else prompt
407
+ payloads = backend.stream_conversation(
408
+ messages=normalized,
409
+ model=model,
410
+ prompt=final_prompt,
411
+ images=images if image_model else None,
412
+ system_hints=["picture_v2"] if image_model else None,
413
+ )
414
+ yield from iter_conversation_payloads(payloads, history_text)
415
+
416
+
417
+ def text_backend() -> OpenAIBackendAPI:
418
+ return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
419
+
420
+
421
+ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
422
+ for event in conversation_events(backend, messages=request.messages, model=request.model, prompt=request.prompt):
423
+ if event.get("type") != "conversation.delta":
424
+ continue
425
+ delta = str(event.get("delta") or "")
426
+ if delta:
427
+ yield delta
428
+
429
+
430
+ def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
431
+ return "".join(stream_text_deltas(backend, request))
432
+
433
+
434
+ def stream_image_outputs(
435
+ backend: OpenAIBackendAPI,
436
+ request: ConversationRequest,
437
+ index: int = 1,
438
+ total: int = 1,
439
+ ) -> Iterator[ImageOutput]:
440
+ last: dict[str, Any] = {}
441
+ for event in conversation_events(
442
+ backend,
443
+ prompt=request.prompt,
444
+ model=request.model,
445
+ images=request.images or [],
446
+ size=request.size,
447
+ ):
448
+ last = event
449
+ if event.get("type") == "conversation.delta":
450
+ yield ImageOutput(
451
+ kind="progress",
452
+ model=request.model,
453
+ index=index,
454
+ total=total,
455
+ text=str(event.get("delta") or ""),
456
+ upstream_event_type="conversation.delta",
457
+ )
458
+ continue
459
+ if event.get("type") == "conversation.event":
460
+ raw = event.get("raw")
461
+ raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
462
+ yield ImageOutput(
463
+ kind="progress",
464
+ model=request.model,
465
+ index=index,
466
+ total=total,
467
+ upstream_event_type=raw_type,
468
+ )
469
+
470
+ conversation_id = str(last.get("conversation_id") or "")
471
+ file_ids = [str(item) for item in last.get("file_ids") or []]
472
+ sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
473
+ message = str(last.get("text") or "").strip()
474
+ is_text_response = last.get("tool_invoked") is False or last.get("turn_use_case") == "text"
475
+ logger.info({
476
+ "event": "image_stream_resolve_start",
477
+ "conversation_id": conversation_id,
478
+ "file_ids": file_ids,
479
+ "sediment_ids": sediment_ids,
480
+ "tool_invoked": last.get("tool_invoked"),
481
+ "turn_use_case": last.get("turn_use_case"),
482
+ })
483
+ if message and not file_ids and not sediment_ids and (last.get("blocked") or is_text_response):
484
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
485
+ return
486
+
487
+ image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
488
+ if image_urls:
489
+ image_items = [
490
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
491
+ for image_data in backend.download_image_bytes(image_urls)
492
+ ]
493
+ data = format_image_result(
494
+ image_items,
495
+ request.prompt,
496
+ request.response_format,
497
+ request.base_url,
498
+ int(time.time()),
499
+ )["data"]
500
+ if data:
501
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
502
+ return
503
+
504
+ if message:
505
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
506
+
507
+
508
+ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
509
+ if str(request.model or "").strip() not in IMAGE_MODELS:
510
+ raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(IMAGE_MODELS))
511
+
512
+ emitted = False
513
+ last_error = ""
514
+ for index in range(1, request.n + 1):
515
+ while True:
516
+ try:
517
+ token = account_service.get_available_access_token()
518
+ except RuntimeError as exc:
519
+ if emitted:
520
+ return
521
+ raise ImageGenerationError(str(exc) or "image generation failed") from exc
522
+
523
+ emitted_for_token = False
524
+ returned_message = False
525
+ returned_result = False
526
+ try:
527
+ backend = OpenAIBackendAPI(access_token=token)
528
+ for output in stream_image_outputs(backend, request, index, request.n):
529
+ if output.kind == "message" and request.message_as_error:
530
+ raise ImageGenerationError(
531
+ output.text or "Image generation was rejected by upstream policy.",
532
+ status_code=400,
533
+ error_type="invalid_request_error",
534
+ code="content_policy_violation",
535
+ )
536
+ emitted = True
537
+ emitted_for_token = True
538
+ returned_message = output.kind == "message"
539
+ returned_result = returned_result or output.kind == "result"
540
+ yield output
541
+ if returned_message or not returned_result:
542
+ account_service.mark_image_result(token, False)
543
+ return
544
+ account_service.mark_image_result(token, True)
545
+ break
546
+ except ImageGenerationError:
547
+ account_service.mark_image_result(token, False)
548
+ raise
549
+ except Exception as exc:
550
+ account_service.mark_image_result(token, False)
551
+ last_error = str(exc)
552
+ logger.warning({"event": "image_stream_fail", "request_token": token, "error": last_error})
553
+ if not emitted_for_token and is_token_invalid_error(last_error):
554
+ account_service.remove_invalid_token(token, "image_stream")
555
+ continue
556
+ raise ImageGenerationError(last_error or "image generation failed") from exc
557
+
558
+ if not emitted:
559
+ raise ImageGenerationError(last_error or "image generation failed")
560
+
561
+
562
+ def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
563
+ for output in outputs:
564
+ yield output.to_chunk()
565
+
566
+
567
+ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
568
+ created = None
569
+ data: list[dict[str, Any]] = []
570
+ message = ""
571
+ progress_parts: list[str] = []
572
+ for output in outputs:
573
+ created = created or output.created
574
+ if output.kind == "progress" and output.text:
575
+ progress_parts.append(output.text)
576
+ elif output.kind == "message":
577
+ message = output.text
578
+ elif output.kind == "result":
579
+ data.extend(output.data)
580
+
581
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
582
+ if not data:
583
+ text = message or "".join(progress_parts).strip()
584
+ if text:
585
+ result["message"] = text
586
+ return result
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,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ response_format = str(body.get("response_format") or "b64_json")
22
+ base_url = str(body.get("base_url") or "") or None
23
+ encoded_images = encode_images(images)
24
+ if not encoded_images:
25
+ raise ImageGenerationError("image is required")
26
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
27
+ prompt=prompt,
28
+ model=model,
29
+ n=n,
30
+ size=size,
31
+ response_format=response_format,
32
+ base_url=base_url,
33
+ images=encoded_images,
34
+ message_as_error=True,
35
+ ))
36
+ if body.get("stream"):
37
+ return stream_image_chunks(outputs)
38
+ return collect_image_outputs(outputs)
services/protocol/openai_v1_image_generations.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ response_format = str(body.get("response_format") or "b64_json")
19
+ base_url = str(body.get("base_url") or "") or None
20
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
21
+ prompt=prompt,
22
+ model=model,
23
+ n=n,
24
+ size=size,
25
+ response_format=response_format,
26
+ base_url=base_url,
27
+ message_as_error=True,
28
+ ))
29
+ if body.get("stream"):
30
+ return stream_image_chunks(outputs)
31
+ 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,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 extract_response_image(input_value: object) -> tuple[bytes, str] | None:
26
+ if isinstance(input_value, dict):
27
+ images = extract_image_from_message_content(input_value.get("content"))
28
+ return images[0] if images else None
29
+ if not isinstance(input_value, list):
30
+ return None
31
+ for item in reversed(input_value):
32
+ if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_image":
33
+ image_url = str(item.get("image_url") or "")
34
+ if image_url.startswith("data:"):
35
+ header, _, data = image_url.partition(",")
36
+ mime = header.split(";")[0].removeprefix("data:")
37
+ return base64.b64decode(data), mime or "image/png"
38
+ if isinstance(item, dict):
39
+ images = extract_image_from_message_content(item.get("content"))
40
+ if images:
41
+ return images[0]
42
+ return None
43
+
44
+
45
+ def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
46
+ messages: list[dict[str, Any]] = []
47
+ system_text = str(instructions or "").strip()
48
+ if system_text:
49
+ messages.append({"role": "system", "content": system_text})
50
+ if isinstance(input_value, str):
51
+ if input_value.strip():
52
+ messages.append({"role": "user", "content": input_value.strip()})
53
+ return messages
54
+ if isinstance(input_value, dict):
55
+ messages.append({
56
+ "role": str(input_value.get("role") or "user"),
57
+ "content": extract_response_prompt([input_value]) or input_value.get("content") or "",
58
+ })
59
+ return messages
60
+ if isinstance(input_value, list):
61
+ if all(isinstance(item, dict) and item.get("type") for item in input_value):
62
+ text = extract_response_prompt(input_value)
63
+ if text:
64
+ messages.append({"role": "user", "content": text})
65
+ return messages
66
+ for item in input_value:
67
+ if isinstance(item, dict):
68
+ messages.append({
69
+ "role": str(item.get("role") or "user"),
70
+ "content": extract_response_prompt([item]) or item.get("content") or "",
71
+ })
72
+ return messages
73
+
74
+
75
+ def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]:
76
+ return {
77
+ "id": item_id or f"msg_{uuid.uuid4().hex}",
78
+ "type": "message",
79
+ "status": status,
80
+ "role": "assistant",
81
+ "content": [{"type": "output_text", "text": text, "annotations": []}],
82
+ }
83
+
84
+
85
+ def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
86
+ output = []
87
+ for item in data:
88
+ b64_json = str(item.get("b64_json") or "").strip()
89
+ if b64_json:
90
+ output.append({
91
+ "id": item_id or f"ig_{len(output) + 1}",
92
+ "type": "image_generation_call",
93
+ "status": "completed",
94
+ "result": b64_json,
95
+ "revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
96
+ })
97
+ return output
98
+
99
+
100
+ def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
101
+ return {
102
+ "type": "response.created",
103
+ "response": {
104
+ "id": response_id,
105
+ "object": "response",
106
+ "created_at": created,
107
+ "status": "in_progress",
108
+ "error": None,
109
+ "incomplete_details": None,
110
+ "model": model,
111
+ "output": [],
112
+ "parallel_tool_calls": False,
113
+ },
114
+ }
115
+
116
+
117
+ def response_completed(response_id: str, model: str, created: int, output: list[dict[str, Any]]) -> dict[str, Any]:
118
+ return {
119
+ "type": "response.completed",
120
+ "response": {
121
+ "id": response_id,
122
+ "object": "response",
123
+ "created_at": created,
124
+ "status": "completed",
125
+ "error": None,
126
+ "incomplete_details": None,
127
+ "model": model,
128
+ "output": output,
129
+ "parallel_tool_calls": False,
130
+ },
131
+ }
132
+
133
+
134
+ def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]:
135
+ model = str(body.get("model") or "auto").strip() or "auto"
136
+ messages = messages_from_input(body.get("input"), body.get("instructions"))
137
+ response_id = f"resp_{uuid.uuid4().hex}"
138
+ item_id = f"msg_{uuid.uuid4().hex}"
139
+ created = int(time.time())
140
+ full_text = ""
141
+ yield response_created(response_id, model, created)
142
+ yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
143
+ request = ConversationRequest(model=model, messages=messages)
144
+ for delta in stream_text_deltas(backend, request):
145
+ full_text += delta
146
+ yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
147
+ yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
148
+ item = text_output_item(full_text, item_id, "completed")
149
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
150
+ yield response_completed(response_id, model, created, [item])
151
+
152
+
153
+ def stream_image_response(image_outputs: Iterable[ImageOutput], prompt: str, model: str) -> Iterator[dict[str, Any]]:
154
+ response_id = f"resp_{uuid.uuid4().hex}"
155
+ created = int(time.time())
156
+ yield response_created(response_id, model, created)
157
+ for output in image_outputs:
158
+ if output.kind == "message":
159
+ text = output.text
160
+ item = text_output_item(text)
161
+ yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
162
+ yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
163
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
164
+ yield response_completed(response_id, model, created, [item])
165
+ return
166
+ if output.kind != "result":
167
+ continue
168
+ items = image_output_items(prompt, output.data)
169
+ if items:
170
+ item = items[0]
171
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
172
+ yield response_completed(response_id, model, created, [item])
173
+ return
174
+ raise RuntimeError("image generation failed")
175
+
176
+
177
+ def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
178
+ completed = {}
179
+ for event in events:
180
+ if event.get("type") == "response.completed":
181
+ completed = event.get("response") if isinstance(event.get("response"), dict) else {}
182
+ if not completed:
183
+ raise RuntimeError("response generation failed")
184
+ return completed
185
+
186
+
187
+ def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
188
+ if is_text_response_request(body):
189
+ yield from stream_text_response(text_backend(), body)
190
+ return
191
+
192
+ prompt = extract_response_prompt(body.get("input"))
193
+ if not prompt:
194
+ raise HTTPException(status_code=400, detail={"error": "input text is required"})
195
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
196
+ image_info = extract_response_image(body.get("input"))
197
+ if image_info:
198
+ image_data, mime_type = image_info
199
+ images = encode_images([(image_data, "image.png", mime_type)])
200
+ else:
201
+ images = None
202
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
203
+ prompt=prompt,
204
+ model=model,
205
+ size=None if images else "1:1",
206
+ response_format="b64_json",
207
+ images=images,
208
+ ))
209
+ yield from stream_image_response(image_outputs, prompt, model)
210
+
211
+
212
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
213
+ events = response_events(body)
214
+ if body.get("stream"):
215
+ return events
216
+ return collect_response(events)
test/test_generations.py CHANGED
@@ -3,7 +3,7 @@ from test.utils import post_json, save_image
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
 
 
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-2", "n": 1})
7
  for index, item in enumerate(result["data"], start=1):
8
  print(save_image(item["b64_json"], f"generations_{index}"))
9
 
test/test_generations_url.py CHANGED
@@ -6,7 +6,7 @@ def main() -> None:
6
  "/v1/images/generations",
7
  {
8
  "prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
9
- "model": "gpt-image-1",
10
  "n": 1,
11
  "response_format": "url",
12
  },
 
6
  "/v1/images/generations",
7
  {
8
  "prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
9
+ "model": "gpt-image-2",
10
  "n": 1,
11
  "response_format": "url",
12
  },
test/test_image.py CHANGED
@@ -1,13 +1,12 @@
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
 
 
1
  from __future__ import annotations
2
 
3
+ from services.protocol import openai_v1_image_generations
 
4
  from test.utils import save_image
5
 
6
 
7
  def main() -> None:
8
  prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
9
+ data = openai_v1_image_generations.handle({"prompt": prompt, "model": "gpt-5-3", "n": 1})
10
  for index, item in enumerate(data["data"], start=1):
11
  print(save_image(item["b64_json"], f"image_{index}"))
12
 
test/test_v1_chat_completions.py CHANGED
@@ -63,7 +63,7 @@ class ChatCompletionsTests(unittest.TestCase):
63
  f"{BASE_URL}/v1/chat/completions",
64
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
65
  json={
66
- "model": "gpt-image-1",
67
  "messages": [
68
  {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
69
  ],
@@ -86,7 +86,7 @@ class ChatCompletionsTests(unittest.TestCase):
86
  f"{BASE_URL}/v1/chat/completions",
87
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
88
  json={
89
- "model": "gpt-image-1",
90
  "stream": True,
91
  "messages": [
92
  {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
 
63
  f"{BASE_URL}/v1/chat/completions",
64
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
65
  json={
66
+ "model": "gpt-image-2",
67
  "messages": [
68
  {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
69
  ],
 
86
  f"{BASE_URL}/v1/chat/completions",
87
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
88
  json={
89
+ "model": "gpt-image-2",
90
  "stream": True,
91
  "messages": [
92
  {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
test/test_v1_images_edits.py CHANGED
@@ -42,7 +42,7 @@ class ImageEditsTests(unittest.TestCase):
42
  f"{BASE_URL}/v1/images/edits",
43
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
44
  data={
45
- "model": "gpt-image-1",
46
  "prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
47
  "n": "1",
48
  "response_format": "b64_json",
@@ -72,7 +72,7 @@ class ImageEditsTests(unittest.TestCase):
72
  f"{BASE_URL}/v1/images/edits",
73
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
74
  data={
75
- "model": "gpt-image-1",
76
  "prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
77
  "n": "1",
78
  "response_format": "b64_json",
 
42
  f"{BASE_URL}/v1/images/edits",
43
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
44
  data={
45
+ "model": "gpt-image-2",
46
  "prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
47
  "n": "1",
48
  "response_format": "b64_json",
 
72
  f"{BASE_URL}/v1/images/edits",
73
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
74
  data={
75
+ "model": "gpt-image-2",
76
  "prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
77
  "n": "1",
78
  "response_format": "b64_json",
test/test_v1_images_generations.py CHANGED
@@ -19,7 +19,7 @@ class ImageGenerationsTests(unittest.TestCase):
19
  f"{BASE_URL}/v1/images/generations",
20
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
21
  json={
22
- "model": "gpt-image-1",
23
  "prompt": "我想做一张南京城市宣传海报图。",
24
  "n": 1,
25
  "response_format": "b64_json",
@@ -46,7 +46,7 @@ class ImageGenerationsTests(unittest.TestCase):
46
  f"{BASE_URL}/v1/images/generations",
47
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
48
  json={
49
- "model": "gpt-image-1",
50
  "prompt": "我想做一张南京城市宣传海报图。",
51
  "n": 1,
52
  "response_format": "b64_json",
 
19
  f"{BASE_URL}/v1/images/generations",
20
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
21
  json={
22
+ "model": "gpt-image-2",
23
  "prompt": "我想做一张南京城市宣传海报图。",
24
  "n": 1,
25
  "response_format": "b64_json",
 
46
  f"{BASE_URL}/v1/images/generations",
47
  headers={"Authorization": f"Bearer {AUTH_KEY}"},
48
  json={
49
+ "model": "gpt-image-2",
50
  "prompt": "我想做一张南京城市宣传海报图。",
51
  "n": 1,
52
  "response_format": "b64_json",
test/test_v1_messages.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import unittest
6
+
7
+ import requests
8
+
9
+ AUTH_KEY = "chatgpt2api"
10
+ BASE_URL = "http://localhost:8000"
11
+ MODEL = "auto"
12
+
13
+
14
+ class AnthropicMessagesTests(unittest.TestCase):
15
+ @staticmethod
16
+ def _headers() -> dict[str, str]:
17
+ return {
18
+ "x-api-key": AUTH_KEY,
19
+ "anthropic-version": "2023-06-01",
20
+ }
21
+
22
+ def test_message_http(self):
23
+ """测试 Anthropic Messages 的非流式 HTTP 调用。"""
24
+ response = requests.post(
25
+ f"{BASE_URL}/v1/messages",
26
+ headers=self._headers(),
27
+ json={
28
+ "model": MODEL,
29
+ "messages": [
30
+ {"role": "user", "content": "你好,请简单介绍一下你自己。"},
31
+ ],
32
+ },
33
+ timeout=300,
34
+ )
35
+ print("messages non-stream status:")
36
+ print(response.status_code)
37
+ print("messages non-stream result:")
38
+ try:
39
+ print(json.dumps(response.json(), ensure_ascii=False, indent=2))
40
+ except Exception:
41
+ print(response.text)
42
+
43
+ def test_message_stream_http(self):
44
+ """测试 Anthropic Messages 的流式 HTTP 调用。"""
45
+ started_at = time.time()
46
+ response = requests.post(
47
+ f"{BASE_URL}/v1/messages",
48
+ headers=self._headers(),
49
+ json={
50
+ "model": MODEL,
51
+ "stream": True,
52
+ "messages": [
53
+ {"role": "user", "content": "你好,请简单介绍一下你自己。"},
54
+ ],
55
+ },
56
+ stream=True,
57
+ timeout=300,
58
+ )
59
+ headers_at = time.time()
60
+ print("messages stream status:")
61
+ print(response.status_code)
62
+ print("messages stream content-type:")
63
+ print(response.headers.get("content-type", ""))
64
+ print("messages stream response headers:")
65
+ print(f"{headers_at - started_at:6.2f}s")
66
+ if response.status_code != 200:
67
+ print(response.text)
68
+ return
69
+ print("messages stream chunks:")
70
+ for line in response.iter_lines(chunk_size=1):
71
+ if not line:
72
+ continue
73
+ text = line.decode("utf-8", errors="replace")
74
+ print(f"{time.time() - started_at:6.2f}s {text}")
75
+ if not text.startswith("data:"):
76
+ continue
77
+ try:
78
+ payload = json.loads(text[5:].strip())
79
+ except Exception:
80
+ continue
81
+ if payload.get("type") == "message_stop":
82
+ break
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()
test/test_v1_models.py CHANGED
@@ -5,7 +5,7 @@ import unittest
5
 
6
  import requests
7
 
8
- from services.chatgpt_service import ChatGPTService
9
 
10
 
11
  AUTH_KEY = "chatgpt2api"
@@ -15,7 +15,7 @@ BASE_URL = "http://localhost:8000"
15
  class ModelListTests(unittest.TestCase):
16
  def test_list_models_function(self):
17
  """测试直接调用服务层获取模型列表。"""
18
- result = ChatGPTService(None).list_models()
19
  print("function result:")
20
  print(json.dumps(result, ensure_ascii=False, indent=2))
21
 
 
5
 
6
  import requests
7
 
8
+ from services.protocol import openai_v1_models
9
 
10
 
11
  AUTH_KEY = "chatgpt2api"
 
15
  class ModelListTests(unittest.TestCase):
16
  def test_list_models_function(self):
17
  """测试直接调用服务层获取模型列表。"""
18
+ result = openai_v1_models.list_models()
19
  print("function result:")
20
  print(json.dumps(result, ensure_ascii=False, indent=2))
21
 
test/test_v1_responses.py CHANGED
@@ -11,7 +11,7 @@ from test.utils import save_image
11
  AUTH_KEY = "chatgpt2api"
12
  BASE_URL = "http://localhost:8000"
13
  TEXT_MODEL = "auto"
14
- IMAGE_MODEL = "gpt-image-1"
15
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
16
 
17
 
 
11
  AUTH_KEY = "chatgpt2api"
12
  BASE_URL = "http://localhost:8000"
13
  TEXT_MODEL = "auto"
14
+ IMAGE_MODEL = "gpt-image-2"
15
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
16
 
17
 
utils/helper.py CHANGED
@@ -1,13 +1,13 @@
1
  import base64
2
- import json
3
  import hashlib
4
- import uuid
 
5
  import time
 
6
  from pathlib import Path
7
- from typing import Any, Dict, Iterator
8
 
9
  from curl_cffi import requests
10
- import re
11
  from fastapi import HTTPException
12
  from utils.log import logger
13
 
@@ -24,10 +24,7 @@ def is_image_chat_request(body: dict[str, object]) -> bool:
24
  modalities = body.get("modalities")
25
  if model in IMAGE_MODELS:
26
  return True
27
- if isinstance(modalities, list):
28
- normalized = {str(item or "").strip().lower() for item in modalities}
29
- return "image" in normalized
30
- return False
31
 
32
 
33
  def ensure_ok(response: requests.Response, context: str) -> None:
@@ -41,23 +38,6 @@ def ensure_ok(response: requests.Response, context: str) -> None:
41
  raise RuntimeError(f"{context} failed: status={response.status_code}, body={body}")
42
 
43
 
44
- def parse_sse_lines(response: requests.Response) -> Iterator[Dict[str, Any]]:
45
- for raw_line in response.iter_lines():
46
- if not raw_line:
47
- continue
48
- line = raw_line.decode("utf-8", errors="ignore")
49
- if not line.startswith("data:"):
50
- continue
51
- payload = line[5:].strip()
52
- if payload == "[DONE]":
53
- yield {"done": True}
54
- break
55
- try:
56
- yield json.loads(payload)
57
- except json.JSONDecodeError:
58
- yield {"raw": payload}
59
-
60
-
61
  def sse_json_stream(items) -> Iterator[str]:
62
  yield ": stream-open\n\n"
63
  try:
@@ -69,7 +49,10 @@ def sse_json_stream(items) -> Iterator[str]:
69
  "error_type": exc.__class__.__name__,
70
  "error": str(exc),
71
  })
72
- yield f"data: {json.dumps({'error': {'message': str(exc), 'type': exc.__class__.__name__}}, ensure_ascii=False)}\n\n"
 
 
 
73
  yield "data: [DONE]\n\n"
74
 
75
 
@@ -90,6 +73,18 @@ def anthropic_sse_stream(items) -> Iterator[str]:
90
  yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def save_images_from_text(text: str, prefix: str) -> list[Path]:
94
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
95
  matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
@@ -240,22 +235,6 @@ def parse_image_count(raw_value: object) -> int:
240
  return value
241
 
242
 
243
- def build_chat_image_completion(model: str, image_result: dict[str, object]) -> dict[str, object]:
244
- created = int(image_result.get("created") or time.time())
245
- return {
246
- "id": f"chatcmpl-{uuid.uuid4().hex}",
247
- "object": "chat.completion",
248
- "created": created,
249
- "model": model,
250
- "choices": [{
251
- "index": 0,
252
- "message": {"role": "assistant", "content": build_chat_image_markdown_content(image_result)},
253
- "finish_reason": "stop",
254
- }],
255
- "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
256
- }
257
-
258
-
259
  def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
260
  image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
261
  markdown_images: list[str] = []
 
1
  import base64
 
2
  import hashlib
3
+ import json
4
+ import re
5
  import time
6
+ import uuid
7
  from pathlib import Path
8
+ from typing import Any, Iterator
9
 
10
  from curl_cffi import requests
 
11
  from fastapi import HTTPException
12
  from utils.log import logger
13
 
 
24
  modalities = body.get("modalities")
25
  if model in IMAGE_MODELS:
26
  return True
27
+ return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
 
 
 
28
 
29
 
30
  def ensure_ok(response: requests.Response, context: str) -> None:
 
38
  raise RuntimeError(f"{context} failed: status={response.status_code}, body={body}")
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def sse_json_stream(items) -> Iterator[str]:
42
  yield ": stream-open\n\n"
43
  try:
 
49
  "error_type": exc.__class__.__name__,
50
  "error": str(exc),
51
  })
52
+ error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else {
53
+ "error": {"message": str(exc), "type": exc.__class__.__name__}
54
+ }
55
+ yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
56
  yield "data: [DONE]\n\n"
57
 
58
 
 
73
  yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
74
 
75
 
76
+ def iter_sse_payloads(response: requests.Response) -> Iterator[str]:
77
+ for raw_line in response.iter_lines():
78
+ if not raw_line:
79
+ continue
80
+ line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
81
+ if not line.startswith("data:"):
82
+ continue
83
+ payload = line[5:].strip()
84
+ if payload:
85
+ yield payload
86
+
87
+
88
  def save_images_from_text(text: str, prefix: str) -> list[Path]:
89
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
90
  matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
 
235
  return value
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
239
  image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
240
  markdown_images: list[str] = []
utils/log.py CHANGED
@@ -13,11 +13,19 @@ class Logger:
13
  self._logger = logging.getLogger(name)
14
  if not self._logger.handlers:
15
  handler = logging.StreamHandler()
16
- handler.setFormatter(logging.Formatter("%(message)s"))
17
  self._logger.addHandler(handler)
18
- self._logger.setLevel(logging.INFO)
19
  self._logger.propagate = False
20
 
 
 
 
 
 
 
 
 
21
  def _mask_string(self, value: str, keep: int = 10) -> str:
22
  if len(value) <= keep:
23
  return value
@@ -76,16 +84,20 @@ class Logger:
76
  return value
77
 
78
  def debug(self, message: Any) -> None:
79
- self._logger.debug(self._sanitize(message))
 
80
 
81
  def info(self, message: Any) -> None:
82
- self._logger.info(self._sanitize(message))
 
83
 
84
  def warning(self, message: Any) -> None:
85
- self._logger.warning(self._sanitize(message))
 
86
 
87
  def error(self, message: Any) -> None:
88
- self._logger.error(self._sanitize(message))
 
89
 
90
 
91
  logger = Logger()
 
13
  self._logger = logging.getLogger(name)
14
  if not self._logger.handlers:
15
  handler = logging.StreamHandler()
16
+ handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
17
  self._logger.addHandler(handler)
18
+ self._logger.setLevel(logging.DEBUG)
19
  self._logger.propagate = False
20
 
21
+ def _enabled(self, level: str) -> bool:
22
+ try:
23
+ from services.config import config
24
+ levels = set(config.log_levels)
25
+ except Exception:
26
+ levels = set()
27
+ return level in (levels or {"info", "warning", "error"})
28
+
29
  def _mask_string(self, value: str, keep: int = 10) -> str:
30
  if len(value) <= keep:
31
  return value
 
84
  return value
85
 
86
  def debug(self, message: Any) -> None:
87
+ if self._enabled("debug"):
88
+ self._logger.debug(self._sanitize(message))
89
 
90
  def info(self, message: Any) -> None:
91
+ if self._enabled("info"):
92
+ self._logger.info(self._sanitize(message))
93
 
94
  def warning(self, message: Any) -> None:
95
+ if self._enabled("warning"):
96
+ self._logger.warning(self._sanitize(message))
97
 
98
  def error(self, message: Any) -> None:
99
+ if self._enabled("error"):
100
+ self._logger.error(self._sanitize(message))
101
 
102
 
103
  logger = Logger()
uv.lock CHANGED
@@ -325,29 +325,29 @@ wheels = [
325
  [[package]]
326
  name = "httpcore"
327
  version = "1.0.9"
328
- source = { registry = "https://pypi.org/simple" }
329
  dependencies = [
330
  { name = "certifi" },
331
  { name = "h11" },
332
  ]
333
- sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
334
  wheels = [
335
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
336
  ]
337
 
338
  [[package]]
339
  name = "httpx"
340
  version = "0.28.1"
341
- source = { registry = "https://pypi.org/simple" }
342
  dependencies = [
343
  { name = "anyio" },
344
  { name = "certifi" },
345
  { name = "httpcore" },
346
  { name = "idna" },
347
  ]
348
- sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
349
  wheels = [
350
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
351
  ]
352
 
353
  [[package]]
 
325
  [[package]]
326
  name = "httpcore"
327
  version = "1.0.9"
328
+ source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
329
  dependencies = [
330
  { name = "certifi" },
331
  { name = "h11" },
332
  ]
333
+ sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
334
  wheels = [
335
+ { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
336
  ]
337
 
338
  [[package]]
339
  name = "httpx"
340
  version = "0.28.1"
341
+ source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
342
  dependencies = [
343
  { name = "anyio" },
344
  { name = "certifi" },
345
  { name = "httpcore" },
346
  { name = "idna" },
347
  ]
348
+ sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
349
  wheels = [
350
+ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
351
  ]
352
 
353
  [[package]]
web/src/app/image/components/image-results.tsx CHANGED
@@ -1,5 +1,6 @@
1
  "use client";
2
 
 
3
  import { Clock3, LoaderCircle, Sparkles } from "lucide-react";
4
 
5
  import { Button } from "@/components/ui/button";
@@ -9,6 +10,8 @@ import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceIm
9
  export type ImageLightboxItem = {
10
  id: string;
11
  src: string;
 
 
12
  };
13
 
14
  type ImageResultsProps = {
@@ -24,6 +27,18 @@ export function ImageResults({
24
  onContinueEdit,
25
  formatConversationTime,
26
  }: ImageResultsProps) {
 
 
 
 
 
 
 
 
 
 
 
 
27
  if (!selectedConversation) {
28
  return (
29
  <div className="flex h-full min-h-[420px] items-center justify-center text-center">
@@ -58,7 +73,14 @@ export function ImageResults({
58
  }));
59
  const successfulTurnImages = turn.images.flatMap((image) =>
60
  image.status === "success" && image.b64_json
61
- ? [{ id: image.id, src: `data:image/png;base64,${image.b64_json}` }]
 
 
 
 
 
 
 
62
  : [],
63
  );
64
 
@@ -125,6 +147,9 @@ export function ImageResults({
125
  {turn.images.map((image, index) => {
126
  if (image.status === "success" && image.b64_json) {
127
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
 
 
 
128
 
129
  return (
130
  <div
@@ -140,10 +165,20 @@ export function ImageResults({
140
  src={`data:image/png;base64,${image.b64_json}`}
141
  alt={`Generated result ${index + 1}`}
142
  className="block h-auto w-full transition duration-200 group-hover:brightness-90"
 
 
 
 
 
 
 
143
  />
144
  </button>
145
  <div className="flex items-center justify-between gap-2 px-3 py-3">
146
- <div className="text-xs text-stone-500">结果 {index + 1}</div>
 
 
 
147
  <Button
148
  variant="outline"
149
  size="sm"
@@ -233,3 +268,21 @@ function getTurnStatusLabel(status: ImageTurnStatus) {
233
  }
234
  return "失败";
235
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  "use client";
2
 
3
+ import { useState } from "react";
4
  import { Clock3, LoaderCircle, Sparkles } from "lucide-react";
5
 
6
  import { Button } from "@/components/ui/button";
 
10
  export type ImageLightboxItem = {
11
  id: string;
12
  src: string;
13
+ sizeLabel?: string;
14
+ dimensions?: string;
15
  };
16
 
17
  type ImageResultsProps = {
 
27
  onContinueEdit,
28
  formatConversationTime,
29
  }: ImageResultsProps) {
30
+ const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
31
+
32
+ const updateImageDimensions = (id: string, width: number, height: number) => {
33
+ const dimensions = formatImageDimensions(width, height);
34
+ setImageDimensions((current) => {
35
+ if (current[id] === dimensions) {
36
+ return current;
37
+ }
38
+ return { ...current, [id]: dimensions };
39
+ });
40
+ };
41
+
42
  if (!selectedConversation) {
43
  return (
44
  <div className="flex h-full min-h-[420px] items-center justify-center text-center">
 
73
  }));
74
  const successfulTurnImages = turn.images.flatMap((image) =>
75
  image.status === "success" && image.b64_json
76
+ ? [
77
+ {
78
+ id: image.id,
79
+ src: `data:image/png;base64,${image.b64_json}`,
80
+ sizeLabel: formatBase64ImageSize(image.b64_json),
81
+ dimensions: imageDimensions[image.id],
82
+ },
83
+ ]
84
  : [],
85
  );
86
 
 
147
  {turn.images.map((image, index) => {
148
  if (image.status === "success" && image.b64_json) {
149
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
150
+ const sizeLabel = formatBase64ImageSize(image.b64_json);
151
+ const dimensions = imageDimensions[image.id];
152
+ const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
153
 
154
  return (
155
  <div
 
165
  src={`data:image/png;base64,${image.b64_json}`}
166
  alt={`Generated result ${index + 1}`}
167
  className="block h-auto w-full transition duration-200 group-hover:brightness-90"
168
+ onLoad={(event) => {
169
+ updateImageDimensions(
170
+ image.id,
171
+ event.currentTarget.naturalWidth,
172
+ event.currentTarget.naturalHeight,
173
+ );
174
+ }}
175
  />
176
  </button>
177
  <div className="flex items-center justify-between gap-2 px-3 py-3">
178
+ <div className="min-w-0 text-xs text-stone-500">
179
+ <span>结果 {index + 1}</span>
180
+ {imageMeta ? <span className="ml-2 text-stone-400">{imageMeta}</span> : null}
181
+ </div>
182
  <Button
183
  variant="outline"
184
  size="sm"
 
268
  }
269
  return "失败";
270
  }
271
+
272
+ function formatBase64ImageSize(base64: string) {
273
+ const normalized = base64.replace(/\s/g, "");
274
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
275
+ const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
276
+
277
+ if (bytes >= 1024 * 1024) {
278
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
279
+ }
280
+ if (bytes >= 1024) {
281
+ return `${(bytes / 1024).toFixed(1)} KB`;
282
+ }
283
+ return `${bytes} B`;
284
+ }
285
+
286
+ function formatImageDimensions(width: number, height: number) {
287
+ return `${width} x ${height}`;
288
+ }
web/src/app/image/components/image-sidebar.tsx CHANGED
@@ -30,7 +30,7 @@ export function ImageSidebar({
30
  hideActionButtons = false,
31
  }: ImageSidebarProps) {
32
  return (
33
- <aside className="min-h-0">
34
  <div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
35
  {!hideActionButtons && (
36
  <div className="flex items-center gap-2">
@@ -49,7 +49,7 @@ export function ImageSidebar({
49
  </div>
50
  )}
51
 
52
- <div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
53
  {isLoadingHistory ? (
54
  <div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
55
  <LoaderCircle className="size-4 animate-spin" />
 
30
  hideActionButtons = false,
31
  }: ImageSidebarProps) {
32
  return (
33
+ <aside className="h-full min-h-0 overflow-hidden">
34
  <div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
35
  {!hideActionButtons && (
36
  <div className="flex items-center gap-2">
 
49
  </div>
50
  )}
51
 
52
+ <div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 [scrollbar-color:rgba(120,113,108,.45)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-stone-400/45 [&::-webkit-scrollbar-track]:bg-transparent">
53
  {isLoadingHistory ? (
54
  <div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
55
  <LoaderCircle className="size-4 animate-spin" />
web/src/app/image/page.tsx CHANGED
@@ -11,6 +11,8 @@ import { ImageLightbox } from "@/components/image-lightbox";
11
  import {
12
  Dialog,
13
  DialogContent,
 
 
14
  DialogHeader,
15
  DialogTitle,
16
  } from "@/components/ui/dialog";
@@ -22,6 +24,7 @@ import {
22
  deleteImageConversation,
23
  getImageConversationStats,
24
  listImageConversations,
 
25
  saveImageConversations,
26
  type ImageConversation,
27
  type ImageConversationMode,
@@ -192,6 +195,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
192
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
193
  const [lightboxOpen, setLightboxOpen] = useState(false);
194
  const [lightboxIndex, setLightboxIndex] = useState(0);
 
195
 
196
  const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
197
  const selectedConversation = useMemo(
@@ -206,6 +210,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
206
  }, 0),
207
  [conversations],
208
  );
 
 
 
 
 
 
 
209
 
210
  useEffect(() => {
211
  conversationsRef.current = conversations;
@@ -328,7 +339,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
328
  ]);
329
  conversationsRef.current = nextConversations;
330
  setConversations(nextConversations);
331
- await saveImageConversations(nextConversations);
332
  };
333
 
334
  const updateConversation = useCallback(
@@ -346,7 +357,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
346
  conversationsRef.current = nextConversations;
347
  setConversations(nextConversations);
348
  if (options.persist !== false) {
349
- await saveImageConversations(nextConversations);
350
  }
351
  },
352
  [],
@@ -407,6 +418,29 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
407
  }
408
  };
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  const appendReferenceImages = useCallback(async (files: File[]) => {
411
  if (files.length === 0) {
412
  return;
@@ -720,7 +754,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
720
  const draftTurn: ImageTurn = {
721
  id: turnId,
722
  prompt,
723
- model: "auto",
724
  mode: imageMode,
725
  referenceImages: imageMode === "edit" ? referenceImages : [],
726
  count: parsedCount,
@@ -772,9 +806,9 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
772
  isLoadingHistory={isLoadingHistory}
773
  selectedConversationId={selectedConversationId}
774
  onCreateDraft={handleCreateDraft}
775
- onClearHistory={handleClearHistory}
776
  onSelectConversation={setSelectedConversationId}
777
- onDeleteConversation={handleDeleteConversation}
778
  formatConversationTime={formatConversationTime}
779
  />
780
  </div>
@@ -796,12 +830,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
796
  handleCreateDraft();
797
  setIsHistoryOpen(false);
798
  }}
799
- onClearHistory={handleClearHistory}
800
  onSelectConversation={(id) => {
801
  setSelectedConversationId(id);
802
  setIsHistoryOpen(false);
803
  }}
804
- onDeleteConversation={handleDeleteConversation}
805
  formatConversationTime={formatConversationTime}
806
  hideActionButtons
807
  />
@@ -829,7 +863,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
829
  <Button
830
  variant="outline"
831
  className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
832
- onClick={() => void handleClearHistory()}
833
  disabled={conversations.length === 0}
834
  >
835
  <Trash2 className="size-4" />
@@ -877,6 +911,27 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
877
  onOpenChange={setLightboxOpen}
878
  onIndexChange={setLightboxIndex}
879
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
  </>
881
  );
882
  }
 
11
  import {
12
  Dialog,
13
  DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
  DialogHeader,
17
  DialogTitle,
18
  } from "@/components/ui/dialog";
 
24
  deleteImageConversation,
25
  getImageConversationStats,
26
  listImageConversations,
27
+ saveImageConversation,
28
  saveImageConversations,
29
  type ImageConversation,
30
  type ImageConversationMode,
 
195
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
196
  const [lightboxOpen, setLightboxOpen] = useState(false);
197
  const [lightboxIndex, setLightboxIndex] = useState(0);
198
+ const [deleteConfirm, setDeleteConfirm] = useState<{ type: "one"; id: string } | { type: "all" } | null>(null);
199
 
200
  const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
201
  const selectedConversation = useMemo(
 
210
  }, 0),
211
  [conversations],
212
  );
213
+ const deleteConfirmTitle = deleteConfirm?.type === "all" ? "清空历史记录" : deleteConfirm?.type === "one" ? "删除对话" : "";
214
+ const deleteConfirmDescription =
215
+ deleteConfirm?.type === "all"
216
+ ? "确认删除全部图片历史记录吗?删除后无法恢复。"
217
+ : deleteConfirm?.type === "one"
218
+ ? "确认删除这条图片对话吗?删除后无法恢复。"
219
+ : "";
220
 
221
  useEffect(() => {
222
  conversationsRef.current = conversations;
 
339
  ]);
340
  conversationsRef.current = nextConversations;
341
  setConversations(nextConversations);
342
+ await saveImageConversation(conversation);
343
  };
344
 
345
  const updateConversation = useCallback(
 
357
  conversationsRef.current = nextConversations;
358
  setConversations(nextConversations);
359
  if (options.persist !== false) {
360
+ await saveImageConversation(nextConversation);
361
  }
362
  },
363
  [],
 
418
  }
419
  };
420
 
421
+ const openDeleteConversationConfirm = (id: string) => {
422
+ setIsHistoryOpen(false);
423
+ setDeleteConfirm({ type: "one", id });
424
+ };
425
+
426
+ const openClearHistoryConfirm = () => {
427
+ setIsHistoryOpen(false);
428
+ setDeleteConfirm({ type: "all" });
429
+ };
430
+
431
+ const handleConfirmDelete = async () => {
432
+ const target = deleteConfirm;
433
+ setDeleteConfirm(null);
434
+ if (!target) {
435
+ return;
436
+ }
437
+ if (target.type === "all") {
438
+ await handleClearHistory();
439
+ return;
440
+ }
441
+ await handleDeleteConversation(target.id);
442
+ };
443
+
444
  const appendReferenceImages = useCallback(async (files: File[]) => {
445
  if (files.length === 0) {
446
  return;
 
754
  const draftTurn: ImageTurn = {
755
  id: turnId,
756
  prompt,
757
+ model: "gpt-image-2",
758
  mode: imageMode,
759
  referenceImages: imageMode === "edit" ? referenceImages : [],
760
  count: parsedCount,
 
806
  isLoadingHistory={isLoadingHistory}
807
  selectedConversationId={selectedConversationId}
808
  onCreateDraft={handleCreateDraft}
809
+ onClearHistory={openClearHistoryConfirm}
810
  onSelectConversation={setSelectedConversationId}
811
+ onDeleteConversation={openDeleteConversationConfirm}
812
  formatConversationTime={formatConversationTime}
813
  />
814
  </div>
 
830
  handleCreateDraft();
831
  setIsHistoryOpen(false);
832
  }}
833
+ onClearHistory={openClearHistoryConfirm}
834
  onSelectConversation={(id) => {
835
  setSelectedConversationId(id);
836
  setIsHistoryOpen(false);
837
  }}
838
+ onDeleteConversation={openDeleteConversationConfirm}
839
  formatConversationTime={formatConversationTime}
840
  hideActionButtons
841
  />
 
863
  <Button
864
  variant="outline"
865
  className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
866
+ onClick={openClearHistoryConfirm}
867
  disabled={conversations.length === 0}
868
  >
869
  <Trash2 className="size-4" />
 
911
  onOpenChange={setLightboxOpen}
912
  onIndexChange={setLightboxIndex}
913
  />
914
+
915
+ {deleteConfirm ? (
916
+ <Dialog open onOpenChange={(open) => (!open ? setDeleteConfirm(null) : null)}>
917
+ <DialogContent showCloseButton={false} className="rounded-2xl p-6">
918
+ <DialogHeader className="gap-2">
919
+ <DialogTitle>{deleteConfirmTitle}</DialogTitle>
920
+ <DialogDescription className="text-sm leading-6">
921
+ {deleteConfirmDescription}
922
+ </DialogDescription>
923
+ </DialogHeader>
924
+ <DialogFooter>
925
+ <Button variant="outline" onClick={() => setDeleteConfirm(null)}>
926
+ 取消
927
+ </Button>
928
+ <Button className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => void handleConfirmDelete()}>
929
+ 确认删除
930
+ </Button>
931
+ </DialogFooter>
932
+ </DialogContent>
933
+ </Dialog>
934
+ ) : null}
935
  </>
936
  );
937
  }
web/src/app/layout.tsx CHANGED
@@ -22,7 +22,7 @@ export default function RootLayout({
22
  '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
23
  }}
24
  >
25
- <Toaster position="top-center" richColors />
26
  <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 py-2 text-stone-900 sm:px-6 lg:px-8">
27
  <div className="mx-auto flex max-w-[1440px] flex-col gap-5">
28
  <TopNav />
 
22
  '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
23
  }}
24
  >
25
+ <Toaster position="top-center" richColors offset={48} />
26
  <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 py-2 text-stone-900 sm:px-6 lg:px-8">
27
  <div className="mx-auto flex max-w-[1440px] flex-col gap-5">
28
  <TopNav />
web/src/app/logs/page.tsx CHANGED
@@ -53,6 +53,7 @@ function LogsContent() {
53
  const [startDate, setStartDate] = useState("");
54
  const [endDate, setEndDate] = useState("");
55
  const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
 
56
  const [lightboxIndex, setLightboxIndex] = useState(0);
57
  const [lightboxOpen, setLightboxOpen] = useState(false);
58
  const [page, setPage] = useState(1);
@@ -83,6 +84,11 @@ function LogsContent() {
83
  setEndDate("");
84
  };
85
 
 
 
 
 
 
86
  useEffect(() => {
87
  void loadLogs();
88
  }, [type, startDate, endDate]);
@@ -151,7 +157,7 @@ function LogsContent() {
151
  ) : null}
152
  <TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
153
  <TableCell>
154
- <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => setDetailLog(item)}>
155
  查看详情
156
  </Button>
157
  </TableCell>
@@ -172,7 +178,7 @@ function LogsContent() {
172
  {!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
173
  </CardContent>
174
  </Card>
175
- <Dialog open={Boolean(detailLog)} onOpenChange={(open) => (!open ? setDetailLog(null) : null)}>
176
  <DialogContent className="w-[min(92vw,920px)] rounded-2xl p-6">
177
  <DialogHeader>
178
  <DialogTitle>日志详情</DialogTitle>
 
53
  const [startDate, setStartDate] = useState("");
54
  const [endDate, setEndDate] = useState("");
55
  const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
56
+ const [detailOpen, setDetailOpen] = useState(false);
57
  const [lightboxIndex, setLightboxIndex] = useState(0);
58
  const [lightboxOpen, setLightboxOpen] = useState(false);
59
  const [page, setPage] = useState(1);
 
84
  setEndDate("");
85
  };
86
 
87
+ const openDetail = (item: SystemLog) => {
88
+ setDetailLog(item);
89
+ setDetailOpen(true);
90
+ };
91
+
92
  useEffect(() => {
93
  void loadLogs();
94
  }, [type, startDate, endDate]);
 
157
  ) : null}
158
  <TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
159
  <TableCell>
160
+ <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => openDetail(item)}>
161
  查看详情
162
  </Button>
163
  </TableCell>
 
178
  {!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
179
  </CardContent>
180
  </Card>
181
+ <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
182
  <DialogContent className="w-[min(92vw,920px)] rounded-2xl p-6">
183
  <DialogHeader>
184
  <DialogTitle>日志详情</DialogTitle>
web/src/app/settings/components/config-card.tsx CHANGED
@@ -15,12 +15,14 @@ import { useSettingsStore } from "../store";
15
  export function ConfigCard() {
16
  const [isTestingProxy, setIsTestingProxy] = useState(false);
17
  const [proxyTestResult, setProxyTestResult] = useState<ProxyTestResult | null>(null);
 
18
  const config = useSettingsStore((state) => state.config);
19
  const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
20
  const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
21
  const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
22
  const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
23
  const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
 
24
  const setProxy = useSettingsStore((state) => state.setProxy);
25
  const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
26
  const saveConfig = useSettingsStore((state) => state.saveConfig);
@@ -140,6 +142,23 @@ export function ConfigCard() {
140
  />
141
  自动移除异常账号
142
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  </div>
144
 
145
  <div className="flex justify-end">
 
15
  export function ConfigCard() {
16
  const [isTestingProxy, setIsTestingProxy] = useState(false);
17
  const [proxyTestResult, setProxyTestResult] = useState<ProxyTestResult | null>(null);
18
+ const logLevelOptions = ["debug", "info", "warning", "error"];
19
  const config = useSettingsStore((state) => state.config);
20
  const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
21
  const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
22
  const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
23
  const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
24
  const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
25
+ const setLogLevel = useSettingsStore((state) => state.setLogLevel);
26
  const setProxy = useSettingsStore((state) => state.setProxy);
27
  const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
28
  const saveConfig = useSettingsStore((state) => state.saveConfig);
 
142
  />
143
  自动移除异常账号
144
  </label>
145
+ <div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
146
+ <div>
147
+ <label className="text-sm text-stone-700">控制台日志级别</label>
148
+ <p className="mt-1 text-xs text-stone-500">不选择时使用默认 info / warning / error。</p>
149
+ </div>
150
+ <div className="grid grid-cols-2 gap-2">
151
+ {logLevelOptions.map((level) => (
152
+ <label key={level} className="flex items-center gap-2 text-sm capitalize text-stone-700">
153
+ <Checkbox
154
+ checked={Boolean(config?.log_levels?.includes(level))}
155
+ onCheckedChange={(checked) => setLogLevel(level, Boolean(checked))}
156
+ />
157
+ {level}
158
+ </label>
159
+ ))}
160
+ </div>
161
+ </div>
162
  </div>
163
 
164
  <div className="flex justify-end">
web/src/app/settings/store.ts CHANGED
@@ -27,6 +27,7 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
27
  refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
28
  image_retention_days: Number(config.image_retention_days || 30),
29
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
 
30
  proxy: typeof config.proxy === "string" ? config.proxy : "",
31
  base_url: typeof config.base_url === "string" ? config.base_url : "",
32
  };
@@ -82,6 +83,7 @@ type SettingsStore = {
82
  setRefreshAccountIntervalMinute: (value: string) => void;
83
  setImageRetentionDays: (value: string) => void;
84
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
 
85
  setProxy: (value: string) => void;
86
  setBaseUrl: (value: string) => void;
87
 
@@ -200,6 +202,16 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
200
  set((state) => state.config ? { config: { ...state.config, auto_remove_invalid_accounts: value } } : {});
201
  },
202
 
 
 
 
 
 
 
 
 
 
 
203
  setProxy: (value) => {
204
  set((state) => {
205
  if (!state.config) {
 
27
  refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
28
  image_retention_days: Number(config.image_retention_days || 30),
29
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
30
+ log_levels: Array.isArray(config.log_levels) ? config.log_levels : [],
31
  proxy: typeof config.proxy === "string" ? config.proxy : "",
32
  base_url: typeof config.base_url === "string" ? config.base_url : "",
33
  };
 
83
  setRefreshAccountIntervalMinute: (value: string) => void;
84
  setImageRetentionDays: (value: string) => void;
85
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
86
+ setLogLevel: (level: string, enabled: boolean) => void;
87
  setProxy: (value: string) => void;
88
  setBaseUrl: (value: string) => void;
89
 
 
202
  set((state) => state.config ? { config: { ...state.config, auto_remove_invalid_accounts: value } } : {});
203
  },
204
 
205
+ setLogLevel: (level, enabled) => {
206
+ set((state) => {
207
+ if (!state.config) return {};
208
+ const levels = new Set(state.config.log_levels || []);
209
+ if (enabled) levels.add(level);
210
+ else levels.delete(level);
211
+ return { config: { ...state.config, log_levels: Array.from(levels) } };
212
+ });
213
+ },
214
+
215
  setProxy: (value) => {
216
  set((state) => {
217
  if (!state.config) {
web/src/lib/api.ts CHANGED
@@ -2,7 +2,7 @@ import { httpRequest } from "@/lib/request";
2
 
3
  export type AccountType = "Free" | "Plus" | "ProLite" | "Pro" | "Team";
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
- export type ImageModel = "auto" | "gpt-image-1" | "gpt-image-2";
6
  export type AuthRole = "admin" | "user";
7
 
8
  export type Account = {
@@ -56,6 +56,7 @@ export type SettingsConfig = {
56
  refresh_account_interval_minute?: number | string;
57
  image_retention_days?: number | string;
58
  auto_remove_invalid_accounts?: boolean;
 
59
  [key: string]: unknown;
60
  };
61
 
@@ -75,6 +76,11 @@ export type SystemLog = {
75
  [key: string]: unknown;
76
  };
77
 
 
 
 
 
 
78
  export type LoginResponse = {
79
  ok: boolean;
80
  version: string;
@@ -147,7 +153,7 @@ export async function updateAccount(
147
  }
148
 
149
  export async function generateImage(prompt: string, model?: ImageModel, size?: string) {
150
- return httpRequest<{ created: number; data: Array<{ b64_json: string; revised_prompt?: string }> }>(
151
  "/v1/images/generations",
152
  {
153
  method: "POST",
@@ -178,7 +184,7 @@ export async function editImage(files: File | File[], prompt: string, model?: Im
178
  }
179
  formData.append("n", "1");
180
 
181
- return httpRequest<{ created: number; data: Array<{ b64_json: string; revised_prompt?: string }> }>(
182
  "/v1/images/edits",
183
  {
184
  method: "POST",
 
2
 
3
  export type AccountType = "Free" | "Plus" | "ProLite" | "Pro" | "Team";
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
+ export type ImageModel = "gpt-image-2" | "codex-gpt-image-2";
6
  export type AuthRole = "admin" | "user";
7
 
8
  export type Account = {
 
56
  refresh_account_interval_minute?: number | string;
57
  image_retention_days?: number | string;
58
  auto_remove_invalid_accounts?: boolean;
59
+ log_levels?: string[];
60
  [key: string]: unknown;
61
  };
62
 
 
76
  [key: string]: unknown;
77
  };
78
 
79
+ export type ImageResponse = {
80
+ created: number;
81
+ data: Array<{ b64_json: string; revised_prompt?: string }>;
82
+ };
83
+
84
  export type LoginResponse = {
85
  ok: boolean;
86
  version: string;
 
153
  }
154
 
155
  export async function generateImage(prompt: string, model?: ImageModel, size?: string) {
156
+ return httpRequest<ImageResponse>(
157
  "/v1/images/generations",
158
  {
159
  method: "POST",
 
184
  }
185
  formData.append("n", "1");
186
 
187
+ return httpRequest<ImageResponse>(
188
  "/v1/images/edits",
189
  {
190
  method: "POST",
web/src/lib/request.ts CHANGED
@@ -7,6 +7,27 @@ type RequestConfig = AxiosRequestConfig & {
7
  redirectOnUnauthorized?: boolean;
8
  };
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  const request = axios.create({
11
  baseURL: webConfig.apiUrl.replace(/\/$/, ""),
12
  });
@@ -26,7 +47,7 @@ request.interceptors.request.use(async (config) => {
26
 
27
  request.interceptors.response.use(
28
  (response) => response,
29
- async (error: AxiosError<{ detail?: { error?: string }; error?: string; message?: string }>) => {
30
  const status = error.response?.status;
31
  const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
32
  if (status === 401 && shouldRedirect && typeof window !== "undefined") {
@@ -42,8 +63,8 @@ request.interceptors.response.use(
42
 
43
  const payload = error.response?.data;
44
  const message =
45
- payload?.detail?.error ||
46
- payload?.error ||
47
  payload?.message ||
48
  error.message ||
49
  `请求失败 (${status || 500})`;
 
7
  redirectOnUnauthorized?: boolean;
8
  };
9
 
10
+ type ErrorPayload = {
11
+ detail?: string | { error?: string | { message?: string } };
12
+ error?: string | { message?: string };
13
+ message?: string;
14
+ };
15
+
16
+ function errorMessageFromValue(value: unknown): string {
17
+ if (typeof value === "string") {
18
+ return value;
19
+ }
20
+ if (!value || typeof value !== "object") {
21
+ return "";
22
+ }
23
+
24
+ const item = value as { error?: unknown; message?: unknown };
25
+ if (typeof item.message === "string") {
26
+ return item.message;
27
+ }
28
+ return errorMessageFromValue(item.error);
29
+ }
30
+
31
  const request = axios.create({
32
  baseURL: webConfig.apiUrl.replace(/\/$/, ""),
33
  });
 
47
 
48
  request.interceptors.response.use(
49
  (response) => response,
50
+ async (error: AxiosError<ErrorPayload>) => {
51
  const status = error.response?.status;
52
  const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
53
  if (status === 401 && shouldRedirect && typeof window !== "undefined") {
 
63
 
64
  const payload = error.response?.data;
65
  const message =
66
+ errorMessageFromValue(payload?.detail) ||
67
+ errorMessageFromValue(payload?.error) ||
68
  payload?.message ||
69
  error.message ||
70
  `请求失败 (${status || 500})`;
web/src/store/image-conversations.ts CHANGED
@@ -120,7 +120,7 @@ function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
120
  return {
121
  id: String(turn.id || `${Date.now()}`),
122
  prompt: String(turn.prompt || ""),
123
- model: (turn.model as ImageModel) || "auto",
124
  mode: turn.mode === "edit" ? "edit" : "generate",
125
  referenceImages: getLegacyReferenceImages(turn),
126
  count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
@@ -145,7 +145,7 @@ function normalizeConversation(conversation: ImageConversation & Record<string,
145
  normalizeTurn({
146
  id: String(conversation.id || `${Date.now()}`),
147
  prompt: String(conversation.prompt || ""),
148
- model: (conversation.model as ImageModel) || "auto",
149
  mode: conversation.mode === "edit" ? "edit" : "generate",
150
  referenceImages: getLegacyReferenceImages(conversation),
151
  count: Number(conversation.count || 1),
@@ -174,6 +174,15 @@ function sortImageConversations(conversations: ImageConversation[]): ImageConver
174
  return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
175
  }
176
 
 
 
 
 
 
 
 
 
 
177
  function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> {
178
  const result = imageConversationWriteQueue.then(operation);
179
  imageConversationWriteQueue = result.then(
@@ -185,8 +194,9 @@ function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T>
185
 
186
  async function readStoredImageConversations(): Promise<ImageConversation[]> {
187
  const items =
188
- (await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(IMAGE_CONVERSATIONS_KEY)) ||
189
- [];
 
190
  return items.map(normalizeConversation);
191
  }
192
 
@@ -196,17 +206,28 @@ export async function listImageConversations(): Promise<ImageConversation[]> {
196
 
197
  export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> {
198
  await queueImageConversationWrite(async () => {
199
- const normalizedItems = sortImageConversations(conversations.map(normalizeConversation));
200
- await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, normalizedItems);
 
 
 
 
 
 
 
 
201
  });
202
  }
203
 
204
  export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
205
  await queueImageConversationWrite(async () => {
206
  const items = await readStoredImageConversations();
 
 
 
207
  const nextItems = sortImageConversations([
208
- normalizeConversation(conversation),
209
- ...items.filter((item) => item.id !== conversation.id),
210
  ]);
211
  await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
212
  });
 
120
  return {
121
  id: String(turn.id || `${Date.now()}`),
122
  prompt: String(turn.prompt || ""),
123
+ model: (turn.model as ImageModel) || "gpt-image-2",
124
  mode: turn.mode === "edit" ? "edit" : "generate",
125
  referenceImages: getLegacyReferenceImages(turn),
126
  count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
 
145
  normalizeTurn({
146
  id: String(conversation.id || `${Date.now()}`),
147
  prompt: String(conversation.prompt || ""),
148
+ model: (conversation.model as ImageModel) || "gpt-image-2",
149
  mode: conversation.mode === "edit" ? "edit" : "generate",
150
  referenceImages: getLegacyReferenceImages(conversation),
151
  count: Number(conversation.count || 1),
 
174
  return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
175
  }
176
 
177
+ function getTimestamp(value: string) {
178
+ const time = new Date(value).getTime();
179
+ return Number.isFinite(time) ? time : 0;
180
+ }
181
+
182
+ function pickLatestConversation(current: ImageConversation, next: ImageConversation) {
183
+ return getTimestamp(next.updatedAt) >= getTimestamp(current.updatedAt) ? next : current;
184
+ }
185
+
186
  function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> {
187
  const result = imageConversationWriteQueue.then(operation);
188
  imageConversationWriteQueue = result.then(
 
194
 
195
  async function readStoredImageConversations(): Promise<ImageConversation[]> {
196
  const items =
197
+ (await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(
198
+ IMAGE_CONVERSATIONS_KEY,
199
+ )) || [];
200
  return items.map(normalizeConversation);
201
  }
202
 
 
206
 
207
  export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> {
208
  await queueImageConversationWrite(async () => {
209
+ const items = await readStoredImageConversations();
210
+ const conversationMap = new Map(items.map((item) => [item.id, item]));
211
+ for (const conversation of conversations.map(normalizeConversation)) {
212
+ const current = conversationMap.get(conversation.id);
213
+ conversationMap.set(conversation.id, current ? pickLatestConversation(current, conversation) : conversation);
214
+ }
215
+ await imageConversationStorage.setItem(
216
+ IMAGE_CONVERSATIONS_KEY,
217
+ sortImageConversations([...conversationMap.values()]),
218
+ );
219
  });
220
  }
221
 
222
  export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
223
  await queueImageConversationWrite(async () => {
224
  const items = await readStoredImageConversations();
225
+ const nextConversation = normalizeConversation(conversation);
226
+ const current = items.find((item) => item.id === nextConversation.id);
227
+ const persistedConversation = current ? pickLatestConversation(current, nextConversation) : nextConversation;
228
  const nextItems = sortImageConversations([
229
+ persistedConversation,
230
+ ...items.filter((item) => item.id !== persistedConversation.id),
231
  ]);
232
  await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
233
  });