HouYunFei commited on
Commit
e8bdc10
·
1 Parent(s): a4564e7

feat(protocol): 添加图像生成服务的账户邮箱追踪功能

Browse files

- 在 ImageGenerationError 和 ImageOutput 类中添加 account_email 字段
- 实现 codex 图像响应的错误解析和事件统计功能
- 集成账户邮箱到图像生成流处理和日志记录流程
- 添加内部

README.md CHANGED
@@ -13,12 +13,7 @@
13
  > - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
14
  > - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
15
  > - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
16
-
17
- > [!IMPORTANT]
18
- > 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
19
-
20
- > [!CAUTION]
21
- > 旧版本存在已知漏洞,请尽快升级到最新版本。公网部署时请尽量不要放置敏感信息,并自行做好访问控制与隔离。
22
 
23
  ## 快速开始
24
 
@@ -124,7 +119,6 @@ environment:
124
 
125
  ## 效果展示
126
 
127
-
128
  <table width="100%">
129
  <tr>
130
  <td width="50%"><img src="https://i.ibb.co/Jj8nfwwP/image.png" alt="image" border="0"></td>
@@ -139,7 +133,6 @@ environment:
139
  </tr>
140
  </table>
141
 
142
-
143
  ## API
144
 
145
  所有 AI 接口都需要请求头:
 
13
  > - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
14
  > - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
15
  > - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
16
+ > - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
 
 
 
 
 
17
 
18
  ## 快速开始
19
 
 
119
 
120
  ## 效果展示
121
 
 
122
  <table width="100%">
123
  <tr>
124
  <td width="50%"><img src="https://i.ibb.co/Jj8nfwwP/image.png" alt="image" border="0"></td>
 
133
  </tr>
134
  </table>
135
 
 
136
  ## API
137
 
138
  所有 AI 接口都需要请求头:
services/image_task_service.py CHANGED
@@ -237,13 +237,17 @@ class ImageTaskService:
237
  if not isinstance(result, dict):
238
  raise RuntimeError("image task returned streaming result unexpectedly")
239
  data = result.get("data")
 
240
  if not isinstance(data, list) or not data:
241
  upstream = _clean(result.get("message"))
242
  if upstream:
243
  message = upstream
244
  else:
245
  message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
246
- raise RuntimeError(message)
 
 
 
247
  usage = result.get("usage")
248
  self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="")
249
  self._log_call(
@@ -254,9 +258,11 @@ class ImageTaskService:
254
  "调用完成",
255
  request_preview=request_text(payload.get("prompt")),
256
  urls=_collect_image_urls(data),
 
257
  )
258
  except Exception as exc:
259
  error_message = str(exc) or "image task failed"
 
260
  self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
261
  self._log_call(
262
  identity,
@@ -267,6 +273,7 @@ class ImageTaskService:
267
  request_preview=request_text(payload.get("prompt")),
268
  status="failed",
269
  error=error_message,
 
270
  )
271
 
272
  def _log_call(
@@ -281,6 +288,7 @@ class ImageTaskService:
281
  status: str = "success",
282
  error: str = "",
283
  urls: list[str] | None = None,
 
284
  ) -> None:
285
  endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
286
  summary_prefix = "图生图" if mode == "edit" else "文生图"
@@ -299,6 +307,8 @@ class ImageTaskService:
299
  detail["request_text"] = request_preview
300
  if error:
301
  detail["error"] = error
 
 
302
  if urls:
303
  detail["urls"] = list(dict.fromkeys(urls))
304
  try:
 
237
  if not isinstance(result, dict):
238
  raise RuntimeError("image task returned streaming result unexpectedly")
239
  data = result.get("data")
240
+ account_email = _clean(result.get("_account_email") or result.get("account_email"))
241
  if not isinstance(data, list) or not data:
242
  upstream = _clean(result.get("message"))
243
  if upstream:
244
  message = upstream
245
  else:
246
  message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
247
+ error = RuntimeError(message)
248
+ if account_email:
249
+ setattr(error, "account_email", account_email)
250
+ raise error
251
  usage = result.get("usage")
252
  self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="")
253
  self._log_call(
 
258
  "调用完成",
259
  request_preview=request_text(payload.get("prompt")),
260
  urls=_collect_image_urls(data),
261
+ account_email=account_email,
262
  )
263
  except Exception as exc:
264
  error_message = str(exc) or "image task failed"
265
+ account_email = _clean(getattr(exc, "account_email", ""))
266
  self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
267
  self._log_call(
268
  identity,
 
273
  request_preview=request_text(payload.get("prompt")),
274
  status="failed",
275
  error=error_message,
276
+ account_email=account_email,
277
  )
278
 
279
  def _log_call(
 
288
  status: str = "success",
289
  error: str = "",
290
  urls: list[str] | None = None,
291
+ account_email: str = "",
292
  ) -> None:
293
  endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
294
  summary_prefix = "图生图" if mode == "edit" else "文生图"
 
307
  detail["request_text"] = request_preview
308
  if error:
309
  detail["error"] = error
310
+ if account_email:
311
+ detail["account_email"] = account_email
312
  if urls:
313
  detail["urls"] = list(dict.fromkeys(urls))
314
  try:
services/log_service.py CHANGED
@@ -20,6 +20,7 @@ from utils.helper import anthropic_sse_stream, sse_json_stream
20
 
21
  LOG_TYPE_CALL = "call"
22
  LOG_TYPE_ACCOUNT = "account"
 
23
 
24
 
25
  class LogService:
@@ -128,6 +129,32 @@ def _collect_urls(value: object) -> list[str]:
128
  return urls
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def _request_excerpt(text: object, limit: int = 1000) -> str:
132
  value = str(text or "").strip()
133
  if not value:
@@ -186,30 +213,32 @@ class LoggedCall:
186
  try:
187
  result = await run_in_threadpool(handler, *args)
188
  except ImageGenerationError as exc:
189
- self.log("调用失败", status="failed", error=str(exc))
190
  return _image_error_response(exc)
191
  except HTTPException as exc:
192
  self.log("调用失败", status="failed", error=str(exc.detail))
193
  raise
194
  except Exception as exc:
195
- self.log("调用失败", status="failed", error=str(exc))
196
  return _protocol_error_response(exc, 502, sse)
197
 
198
  if isinstance(result, dict):
199
  self.log("调用完成", result)
200
- return result
 
 
201
 
202
  sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
203
  try:
204
  has_first, first = await run_in_threadpool(_next_item, result)
205
  except ImageGenerationError as exc:
206
- self.log("调用失败", status="failed", error=str(exc))
207
  return _image_error_response(exc)
208
  except HTTPException as exc:
209
  self.log("调用失败", status="failed", error=str(exc.detail))
210
  raise
211
  except Exception as exc:
212
- self.log("调用失败", status="failed", error=str(exc))
213
  return _protocol_error_response(exc, 502, sse)
214
  if not has_first:
215
  self.log("流式调用结束")
@@ -218,21 +247,29 @@ class LoggedCall:
218
 
219
  def stream(self, items):
220
  urls: list[str] = []
 
221
  failed = False
222
  try:
223
  for item in items:
224
  urls.extend(_collect_urls(item))
225
- yield item
 
226
  except Exception as exc:
227
  failed = True
228
- self.log("流式调用失败", status="failed", error=str(exc), urls=urls)
 
 
 
 
 
 
229
  raise
230
  finally:
231
  if not failed:
232
- self.log("流式调用结束", urls=urls)
233
 
234
  def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
235
- urls: list[str] | None = None) -> None:
236
  detail = {
237
  "key_id": self.identity.get("id"),
238
  "key_name": self.identity.get("name"),
@@ -249,6 +286,12 @@ class LoggedCall:
249
  detail["request_text"] = request_excerpt
250
  if error:
251
  detail["error"] = error
 
 
 
 
 
 
252
  collected_urls = [*(urls or []), *_collect_urls(result)]
253
  if collected_urls:
254
  detail["urls"] = list(dict.fromkeys(collected_urls))
 
20
 
21
  LOG_TYPE_CALL = "call"
22
  LOG_TYPE_ACCOUNT = "account"
23
+ INTERNAL_RESPONSE_KEYS = {"_account_email"}
24
 
25
 
26
  class LogService:
 
129
  return urls
130
 
131
 
132
+ def _collect_account_emails(value: object) -> list[str]:
133
+ emails: list[str] = []
134
+ if isinstance(value, dict):
135
+ for key, item in value.items():
136
+ if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
137
+ emails.append(item.strip())
138
+ else:
139
+ emails.extend(_collect_account_emails(item))
140
+ elif isinstance(value, list):
141
+ for item in value:
142
+ emails.extend(_collect_account_emails(item))
143
+ return emails
144
+
145
+
146
+ def _strip_internal_response_fields(value: object) -> object:
147
+ if isinstance(value, dict):
148
+ return {
149
+ key: _strip_internal_response_fields(item)
150
+ for key, item in value.items()
151
+ if key not in INTERNAL_RESPONSE_KEYS
152
+ }
153
+ if isinstance(value, list):
154
+ return [_strip_internal_response_fields(item) for item in value]
155
+ return value
156
+
157
+
158
  def _request_excerpt(text: object, limit: int = 1000) -> str:
159
  value = str(text or "").strip()
160
  if not value:
 
213
  try:
214
  result = await run_in_threadpool(handler, *args)
215
  except ImageGenerationError as exc:
216
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
217
  return _image_error_response(exc)
218
  except HTTPException as exc:
219
  self.log("调用失败", status="failed", error=str(exc.detail))
220
  raise
221
  except Exception as exc:
222
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
223
  return _protocol_error_response(exc, 502, sse)
224
 
225
  if isinstance(result, dict):
226
  self.log("调用完成", result)
227
+ response = dict(result)
228
+ response.pop("_account_email", None)
229
+ return response
230
 
231
  sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
232
  try:
233
  has_first, first = await run_in_threadpool(_next_item, result)
234
  except ImageGenerationError as exc:
235
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
236
  return _image_error_response(exc)
237
  except HTTPException as exc:
238
  self.log("调用失败", status="failed", error=str(exc.detail))
239
  raise
240
  except Exception as exc:
241
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
242
  return _protocol_error_response(exc, 502, sse)
243
  if not has_first:
244
  self.log("流式调用结束")
 
247
 
248
  def stream(self, items):
249
  urls: list[str] = []
250
+ account_emails: list[str] = []
251
  failed = False
252
  try:
253
  for item in items:
254
  urls.extend(_collect_urls(item))
255
+ account_emails.extend(_collect_account_emails(item))
256
+ yield _strip_internal_response_fields(item)
257
  except Exception as exc:
258
  failed = True
259
+ self.log(
260
+ "流式调用失败",
261
+ status="failed",
262
+ error=str(exc),
263
+ urls=urls,
264
+ account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
265
+ )
266
  raise
267
  finally:
268
  if not failed:
269
+ self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "")
270
 
271
  def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
272
+ urls: list[str] | None = None, account_email: str = "") -> None:
273
  detail = {
274
  "key_id": self.identity.get("id"),
275
  "key_name": self.identity.get("name"),
 
286
  detail["request_text"] = request_excerpt
287
  if error:
288
  detail["error"] = error
289
+ email = str(account_email or "").strip()
290
+ if not email:
291
+ emails = _collect_account_emails(result)
292
+ email = emails[0] if emails else ""
293
+ if email:
294
+ detail["account_email"] = email
295
  collected_urls = [*(urls or []), *_collect_urls(result)]
296
  if collected_urls:
297
  detail["urls"] = list(dict.fromkeys(collected_urls))
services/openai_backend_api.py CHANGED
@@ -4,6 +4,8 @@ import os
4
  import random
5
  import re
6
  import time
 
 
7
  from concurrent.futures import ThreadPoolExecutor
8
  from dataclasses import dataclass
9
  from io import BytesIO
@@ -45,11 +47,6 @@ DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
45
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
46
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
47
  CODEX_RESPONSES_MODEL = "gpt-5.5"
48
- CODEX_RESPONSES_VERSION = "0.122.0"
49
- CODEX_RESPONSES_USER_AGENT = (
50
- "codex-tui/0.122.0 (Manjaro 26.1.0-pre; x86_64) "
51
- "vscode/3.0.12 (codex-tui; 0.122.0)"
52
- )
53
  CODEX_RESPONSES_INSTRUCTIONS = (
54
  "Use the image_generation tool to create exactly one image for the user's request. "
55
  "Return the generated image result."
@@ -156,26 +153,6 @@ class OpenAIBackendAPI:
156
  headers.update(extra)
157
  return headers
158
 
159
- @staticmethod
160
- def _decode_token_payload(token: str) -> Dict[str, Any]:
161
- try:
162
- payload = str(token or "").split(".")[1]
163
- payload += "=" * ((4 - len(payload) % 4) % 4)
164
- data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
165
- return data if isinstance(data, dict) else {}
166
- except Exception:
167
- return {}
168
-
169
- def _chatgpt_account_id(self) -> str:
170
- account = account_service.get_account(self.access_token) if self.access_token else {}
171
- account = account if isinstance(account, dict) else {}
172
- if str(account.get("account_id") or "").strip():
173
- return str(account.get("account_id") or "").strip()
174
- payload = self._decode_token_payload(self.access_token)
175
- auth_claim = payload.get("https://api.openai.com/auth")
176
- auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
177
- return str(auth_claim.get("chatgpt_account_id") or account.get("user_id") or "").strip()
178
-
179
  @staticmethod
180
  def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
181
  for item in limits_progress:
@@ -456,19 +433,10 @@ class OpenAIBackendAPI:
456
  return self._headers(path, headers)
457
 
458
  def _codex_responses_headers(self) -> Dict[str, str]:
459
- headers = {
460
  "Authorization": f"Bearer {self.access_token}",
461
- "User-Agent": CODEX_RESPONSES_USER_AGENT,
462
- "version": CODEX_RESPONSES_VERSION,
463
- "originator": "codex_cli_rs",
464
- "session_id": self.session_id,
465
- "Accept": "text/event-stream",
466
  "Content-Type": "application/json",
467
  }
468
- account_id = self._chatgpt_account_id()
469
- if account_id:
470
- headers["chatgpt-account-id"] = account_id
471
- return headers
472
 
473
  def _ensure_codex_source_account(self) -> None:
474
  account = account_service.get_account(self.access_token)
@@ -495,10 +463,64 @@ class OpenAIBackendAPI:
495
  text = str(body or "")
496
  return text if len(text) <= limit else text[:limit] + "...[truncated]"
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  def _log_codex_response_failure(
499
  self,
500
  path: str,
501
- response: requests.Response,
 
502
  payload: Dict[str, Any],
503
  body: Any,
504
  ) -> None:
@@ -506,11 +528,12 @@ class OpenAIBackendAPI:
506
  safe_request_headers = {
507
  key: value for key, value in request_headers.items() if key.lower() != "authorization"
508
  }
 
509
  tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
510
  logger.warning({
511
  "event": "codex_responses_http_error",
512
  "path": path,
513
- "status_code": response.status_code,
514
  "request": {
515
  "model": payload.get("model"),
516
  "tool_model": tool.get("model"),
@@ -525,11 +548,69 @@ class OpenAIBackendAPI:
525
  "headers": safe_request_headers,
526
  },
527
  "response": {
528
- "headers": dict(response.headers or {}),
529
  "body_preview": self._codex_body_preview(body),
530
  },
531
  })
532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  def iter_codex_image_response_events(
534
  self,
535
  prompt: str,
@@ -557,41 +638,67 @@ class OpenAIBackendAPI:
557
  "tool_choice": {"type": "image_generation"},
558
  "stream": True,
559
  }
560
- response = self.session.post(
561
  self.base_url + path,
562
- headers=self._codex_responses_headers(),
563
- json=payload,
564
- timeout=1200,
565
- stream=True,
566
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  try:
568
- if not 200 <= response.status_code < 300:
569
- body: Any = response.text
570
- try:
571
- body = response.json()
572
- except Exception:
573
- pass
574
- self._log_codex_response_failure(path, response, payload, body)
575
- retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
576
- retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
577
- raise UpstreamHTTPError(path, response.status_code, body, retry_after=retry_after)
578
- content_type = str(response.headers.get("content-type") or "").lower()
579
- if "application/json" in content_type:
580
- data = response.json()
581
- if isinstance(data, dict):
582
- yield data
583
- return
584
- for payload_text in iter_sse_payloads(response):
585
- if payload_text == "[DONE]":
586
- break
587
- try:
588
- data = json.loads(payload_text)
589
- except Exception:
590
- continue
591
- if isinstance(data, dict):
592
- yield data
593
- finally:
594
- response.close()
595
 
596
  def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
597
  """为图片生成准备 conduit token。"""
 
4
  import random
5
  import re
6
  import time
7
+ import urllib.error
8
+ import urllib.request
9
  from concurrent.futures import ThreadPoolExecutor
10
  from dataclasses import dataclass
11
  from io import BytesIO
 
47
  DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
48
  CODEX_IMAGE_MODEL = "codex-gpt-image-2"
49
  CODEX_RESPONSES_MODEL = "gpt-5.5"
 
 
 
 
 
50
  CODEX_RESPONSES_INSTRUCTIONS = (
51
  "Use the image_generation tool to create exactly one image for the user's request. "
52
  "Return the generated image result."
 
153
  headers.update(extra)
154
  return headers
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  @staticmethod
157
  def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
158
  for item in limits_progress:
 
433
  return self._headers(path, headers)
434
 
435
  def _codex_responses_headers(self) -> Dict[str, str]:
436
+ return {
437
  "Authorization": f"Bearer {self.access_token}",
 
 
 
 
 
438
  "Content-Type": "application/json",
439
  }
 
 
 
 
440
 
441
  def _ensure_codex_source_account(self) -> None:
442
  account = account_service.get_account(self.access_token)
 
463
  text = str(body or "")
464
  return text if len(text) <= limit else text[:limit] + "...[truncated]"
465
 
466
+ @staticmethod
467
+ def _codex_event_image_result_lengths(value: Any) -> list[int]:
468
+ if isinstance(value, dict):
469
+ lengths: list[int] = []
470
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
471
+ lengths.append(len(value["result"]))
472
+ for item in value.values():
473
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
474
+ return lengths
475
+ if isinstance(value, list):
476
+ lengths: list[int] = []
477
+ for item in value:
478
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
479
+ return lengths
480
+ return []
481
+
482
+ @staticmethod
483
+ def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]:
484
+ summary: Dict[str, Any] = {
485
+ "type": str(event.get("type") or ""),
486
+ "keys": list(event.keys())[:30],
487
+ }
488
+ for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"):
489
+ value = event.get(key)
490
+ if isinstance(value, (str, int, float, bool)) or value is None:
491
+ summary[key] = value
492
+ for key in ("response", "item", "output"):
493
+ value = event.get(key)
494
+ if isinstance(value, dict):
495
+ summary[f"{key}_type"] = value.get("type")
496
+ summary[f"{key}_status"] = value.get("status")
497
+ summary[f"{key}_keys"] = list(value.keys())[:30]
498
+ elif isinstance(value, list):
499
+ summary[f"{key}_len"] = len(value)
500
+ summary[f"{key}_types"] = [
501
+ item.get("type") for item in value[:10] if isinstance(item, dict)
502
+ ]
503
+ error = event.get("error")
504
+ if isinstance(error, dict):
505
+ summary["error"] = {
506
+ key: error.get(key)
507
+ for key in ("type", "code", "message")
508
+ if error.get(key) is not None
509
+ }
510
+ delta = event.get("delta")
511
+ if isinstance(delta, str):
512
+ summary["delta_len"] = len(delta)
513
+ summary["delta_preview"] = delta[:200]
514
+ result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event)
515
+ if result_lengths:
516
+ summary["image_result_lengths"] = result_lengths[:10]
517
+ return summary
518
+
519
  def _log_codex_response_failure(
520
  self,
521
  path: str,
522
+ status_code: int,
523
+ headers: Any,
524
  payload: Dict[str, Any],
525
  body: Any,
526
  ) -> None:
 
528
  safe_request_headers = {
529
  key: value for key, value in request_headers.items() if key.lower() != "authorization"
530
  }
531
+ response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {})
532
  tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
533
  logger.warning({
534
  "event": "codex_responses_http_error",
535
  "path": path,
536
+ "status_code": status_code,
537
  "request": {
538
  "model": payload.get("model"),
539
  "tool_model": tool.get("model"),
 
548
  "headers": safe_request_headers,
549
  },
550
  "response": {
551
+ "headers": response_headers,
552
  "body_preview": self._codex_body_preview(body),
553
  },
554
  })
555
 
556
+ @staticmethod
557
+ def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]:
558
+ content_type = str(raw.headers.get("content-type") or "").lower()
559
+ text = raw.read().decode("utf-8", "replace")
560
+ status_code = getattr(raw, "status", None)
561
+ parse_errors: list[str] = []
562
+ events: list[Dict[str, Any]] = []
563
+ if "application/json" in content_type:
564
+ try:
565
+ data = json.loads(text)
566
+ if isinstance(data, dict):
567
+ events.append(data)
568
+ except Exception as exc:
569
+ parse_errors.append(str(exc))
570
+ else:
571
+ lines: list[str] = []
572
+ for line in text.splitlines() + [""]:
573
+ if not line:
574
+ if lines:
575
+ payload_text = "\n".join(lines).strip()
576
+ if payload_text and payload_text != "[DONE]":
577
+ try:
578
+ data = json.loads(payload_text)
579
+ except Exception as exc:
580
+ parse_errors.append(str(exc))
581
+ data = None
582
+ if isinstance(data, dict):
583
+ events.append(data)
584
+ lines = []
585
+ elif line.startswith("data:"):
586
+ lines.append(line[5:].lstrip())
587
+
588
+ event_types: Dict[str, int] = {}
589
+ image_result_lengths: list[int] = []
590
+ for event in events:
591
+ event_type = str(event.get("type") or "<missing>")
592
+ event_types[event_type] = event_types.get(event_type, 0) + 1
593
+ image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event))
594
+ logger.info({
595
+ "event": "codex_responses_response_debug",
596
+ "status_code": status_code,
597
+ "content_type": content_type,
598
+ "response_text_len": len(text),
599
+ "event_count": len(events),
600
+ "event_types": event_types,
601
+ "image_result_lengths": image_result_lengths[:10],
602
+ "parse_error_count": len(parse_errors),
603
+ "parse_errors": parse_errors[:5],
604
+ "event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]],
605
+ "event_previews": [
606
+ OpenAIBackendAPI._codex_body_preview(event, 1500)
607
+ for event in events[:10]
608
+ ] if not image_result_lengths else [],
609
+ "body_preview": text[:1000] if not events else "",
610
+ })
611
+ for event in events:
612
+ yield event
613
+
614
  def iter_codex_image_response_events(
615
  self,
616
  prompt: str,
 
638
  "tool_choice": {"type": "image_generation"},
639
  "stream": True,
640
  }
641
+ request = urllib.request.Request(
642
  self.base_url + path,
643
+ json.dumps(payload).encode(),
644
+ self._codex_responses_headers(),
645
+ method="POST",
 
646
  )
647
+ account = account_service.get_account(self.access_token) or {}
648
+ token_payload = account_service._decode_jwt_payload(self.access_token)
649
+ auth_claim = token_payload.get("https://api.openai.com/auth")
650
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
651
+ tool = payload["tools"][0]
652
+ logger.info({
653
+ "event": "codex_responses_request_debug",
654
+ "url": self.base_url + path,
655
+ "transport": "urllib.request",
656
+ "timeout_secs": 1200,
657
+ "account_email": str(account.get("email") or "").strip(),
658
+ "source_type": str(account.get("source_type") or "").strip(),
659
+ "account_type": str(account.get("type") or "").strip(),
660
+ "token_claims": {
661
+ "jti": token_payload.get("jti"),
662
+ "iat": token_payload.get("iat"),
663
+ "exp": token_payload.get("exp"),
664
+ "client_id": token_payload.get("client_id"),
665
+ "chatgpt_account_id": auth_claim.get("chatgpt_account_id"),
666
+ "chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"),
667
+ "localhost": auth_claim.get("localhost"),
668
+ },
669
+ "request": {
670
+ "model": payload.get("model"),
671
+ "tool_model": tool.get("model"),
672
+ "tool_action": tool.get("action"),
673
+ "size": tool.get("size"),
674
+ "quality": tool.get("quality"),
675
+ "output_format": tool.get("output_format"),
676
+ "stream": payload.get("stream"),
677
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
678
+ "prompt_preview": self._codex_body_preview(
679
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
680
+ 500,
681
+ ),
682
+ },
683
+ "headers": {
684
+ key: value for key, value in self._codex_responses_headers().items()
685
+ if key.lower() != "authorization"
686
+ },
687
+ })
688
  try:
689
+ with urllib.request.urlopen(request, timeout=1200) as raw:
690
+ yield from self._iter_codex_response_events(raw)
691
+ except urllib.error.HTTPError as error:
692
+ body_text = error.read().decode("utf-8", "replace")
693
+ body: Any = body_text
694
+ try:
695
+ body = json.loads(body_text)
696
+ except Exception:
697
+ pass
698
+ self._log_codex_response_failure(path, error.code, error.headers, payload, body)
699
+ retry_after_header = error.headers.get("Retry-After") if error.headers else None
700
+ retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
701
+ raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
  def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
704
  """为图片生成准备 conduit token。"""
services/protocol/conversation.py CHANGED
@@ -32,12 +32,14 @@ class ImageGenerationError(Exception):
32
  error_type: str = "server_error",
33
  code: str | None = "upstream_error",
34
  param: str | None = None,
 
35
  ) -> None:
36
  super().__init__(message)
37
  self.status_code = status_code
38
  self.error_type = error_type
39
  self.code = code
40
  self.param = param
 
41
 
42
  def to_openai_error(self) -> dict[str, Any]:
43
  return {
@@ -254,6 +256,7 @@ class ImageOutput:
254
  text: str = ""
255
  upstream_event_type: str = ""
256
  data: list[dict[str, Any]] = field(default_factory=list)
 
257
 
258
  def to_chunk(self) -> dict[str, Any]:
259
  chunk: dict[str, Any] = {
@@ -266,6 +269,8 @@ class ImageOutput:
266
  "upstream_event_type": self.upstream_event_type,
267
  "data": [],
268
  }
 
 
269
  if self.kind == "message":
270
  chunk.update({
271
  "object": "image.generation.message",
@@ -646,21 +651,56 @@ def _codex_response_images(value: Any) -> list[str]:
646
  return []
647
 
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  def stream_codex_image_outputs(
650
  backend: OpenAIBackendAPI,
651
  request: ConversationRequest,
652
  index: int = 1,
653
  total: int = 1,
654
  ) -> Iterator[ImageOutput]:
655
- prompt = prompt_with_global_system(build_image_prompt(request.prompt, request.size, request.quality))
656
  text_parts: list[str] = []
 
 
657
  for event in backend.iter_codex_image_response_events(
658
- prompt=prompt,
659
  images=request.images or [],
660
  size=request.size,
661
  quality=request.quality,
662
  ):
663
  event_type = str(event.get("type") or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  if event_type == "response.output_text.delta":
665
  delta = str(event.get("delta") or "")
666
  if delta:
@@ -676,6 +716,16 @@ def stream_codex_image_outputs(
676
  continue
677
  images = _codex_response_images(event)
678
  if images:
 
 
 
 
 
 
 
 
 
 
679
  data = format_image_result(
680
  [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
681
  request.prompt,
@@ -697,6 +747,18 @@ def stream_codex_image_outputs(
697
  message = "".join(text_parts).strip()
698
  if message:
699
  yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
 
 
 
 
 
 
 
 
 
 
 
 
700
 
701
 
702
  def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
@@ -723,16 +785,21 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima
723
  emitted_for_token = False
724
  returned_message = False
725
  returned_result = False
 
 
726
  try:
727
  backend = OpenAIBackendAPI(access_token=token)
728
  stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
729
  for output in stream_fn(backend, request, index, request.n):
 
 
730
  if output.kind == "message" and request.message_as_error:
731
  raise ImageGenerationError(
732
  output.text or "Image generation was rejected by upstream policy.",
733
  status_code=400,
734
  error_type="invalid_request_error",
735
  code="content_policy_violation",
 
736
  )
737
  emitted = True
738
  emitted_for_token = True
@@ -744,15 +811,30 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima
744
  return
745
  account_service.mark_image_result(token, True)
746
  break
747
- except ImagePollTimeoutError:
 
 
748
  raise
749
- except ImageGenerationError:
750
  account_service.mark_image_result(token, False)
 
 
 
 
 
 
 
 
751
  raise
752
  except Exception as exc:
753
  account_service.mark_image_result(token, False)
754
  last_error = str(exc)
755
- logger.warning({"event": "image_stream_fail", "request_token": token, "error": last_error})
 
 
 
 
 
756
  if not emitted_for_token and is_token_invalid_error(last_error):
757
  refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
758
  if refreshed_token and refreshed_token != token:
@@ -760,7 +842,7 @@ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[Ima
760
  continue
761
  account_service.remove_invalid_token(token, "image_stream")
762
  continue
763
- raise ImageGenerationError(image_stream_error_message(last_error)) from exc
764
 
765
  if not emitted:
766
  if not last_error:
@@ -778,8 +860,11 @@ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
778
  data: list[dict[str, Any]] = []
779
  message = ""
780
  progress_parts: list[str] = []
 
781
  for output in outputs:
782
  created = created or output.created
 
 
783
  if output.kind == "progress" and output.text:
784
  progress_parts.append(output.text)
785
  elif output.kind == "message":
@@ -792,4 +877,6 @@ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
792
  text = message or "".join(progress_parts).strip()
793
  if text:
794
  result["message"] = text
 
 
795
  return result
 
32
  error_type: str = "server_error",
33
  code: str | None = "upstream_error",
34
  param: str | None = None,
35
+ account_email: str = "",
36
  ) -> None:
37
  super().__init__(message)
38
  self.status_code = status_code
39
  self.error_type = error_type
40
  self.code = code
41
  self.param = param
42
+ self.account_email = account_email
43
 
44
  def to_openai_error(self) -> dict[str, Any]:
45
  return {
 
256
  text: str = ""
257
  upstream_event_type: str = ""
258
  data: list[dict[str, Any]] = field(default_factory=list)
259
+ account_email: str = ""
260
 
261
  def to_chunk(self) -> dict[str, Any]:
262
  chunk: dict[str, Any] = {
 
269
  "upstream_event_type": self.upstream_event_type,
270
  "data": [],
271
  }
272
+ if self.account_email:
273
+ chunk["_account_email"] = self.account_email
274
  if self.kind == "message":
275
  chunk.update({
276
  "object": "image.generation.message",
 
651
  return []
652
 
653
 
654
+ def _codex_response_error(value: Any) -> tuple[str, str]:
655
+ if not isinstance(value, dict):
656
+ return "", ""
657
+ error = value.get("error")
658
+ if not isinstance(error, dict):
659
+ response = value.get("response")
660
+ error = response.get("error") if isinstance(response, dict) and response.get("status") == "failed" else None
661
+ if not isinstance(error, dict):
662
+ return "", ""
663
+ message = str(error.get("message") or "").strip()
664
+ code = str(error.get("code") or error.get("type") or "server_error").strip()
665
+ return message or "Codex responses failed upstream.", code
666
+
667
+
668
  def stream_codex_image_outputs(
669
  backend: OpenAIBackendAPI,
670
  request: ConversationRequest,
671
  index: int = 1,
672
  total: int = 1,
673
  ) -> Iterator[ImageOutput]:
 
674
  text_parts: list[str] = []
675
+ event_count = 0
676
+ event_types: dict[str, int] = {}
677
  for event in backend.iter_codex_image_response_events(
678
+ prompt=request.prompt,
679
  images=request.images or [],
680
  size=request.size,
681
  quality=request.quality,
682
  ):
683
  event_type = str(event.get("type") or "")
684
+ event_count += 1
685
+ event_types[event_type or "<missing>"] = event_types.get(event_type or "<missing>", 0) + 1
686
+ upstream_error, upstream_code = _codex_response_error(event)
687
+ if upstream_error:
688
+ logger.warning({
689
+ "event": "codex_response_upstream_error",
690
+ "model": request.model,
691
+ "size": request.size,
692
+ "quality": request.quality,
693
+ "event_count": event_count,
694
+ "event_types": event_types,
695
+ "upstream_code": upstream_code,
696
+ "upstream_error": upstream_error,
697
+ })
698
+ raise ImageGenerationError(
699
+ upstream_error,
700
+ status_code=502,
701
+ error_type="server_error",
702
+ code=upstream_code or "server_error",
703
+ )
704
  if event_type == "response.output_text.delta":
705
  delta = str(event.get("delta") or "")
706
  if delta:
 
716
  continue
717
  images = _codex_response_images(event)
718
  if images:
719
+ logger.info({
720
+ "event": "codex_response_image_result_found",
721
+ "model": request.model,
722
+ "size": request.size,
723
+ "quality": request.quality,
724
+ "event_count": event_count,
725
+ "event_types": event_types,
726
+ "image_count": len(images),
727
+ "image_result_lengths": [len(item) for item in images[:10]],
728
+ })
729
  data = format_image_result(
730
  [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
731
  request.prompt,
 
747
  message = "".join(text_parts).strip()
748
  if message:
749
  yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
750
+ return
751
+ logger.warning({
752
+ "event": "codex_response_no_image_result",
753
+ "model": request.model,
754
+ "size": request.size,
755
+ "quality": request.quality,
756
+ "image_input_count": len(request.images or []),
757
+ "event_count": event_count,
758
+ "event_types": event_types,
759
+ "output_text_len": len("".join(text_parts)),
760
+ })
761
+ raise ImageGenerationError("No image result found in response")
762
 
763
 
764
  def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
 
785
  emitted_for_token = False
786
  returned_message = False
787
  returned_result = False
788
+ account = account_service.get_account(token) or {}
789
+ account_email = str(account.get("email") or "").strip()
790
  try:
791
  backend = OpenAIBackendAPI(access_token=token)
792
  stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
793
  for output in stream_fn(backend, request, index, request.n):
794
+ if account_email and not output.account_email:
795
+ output.account_email = account_email
796
  if output.kind == "message" and request.message_as_error:
797
  raise ImageGenerationError(
798
  output.text or "Image generation was rejected by upstream policy.",
799
  status_code=400,
800
  error_type="invalid_request_error",
801
  code="content_policy_violation",
802
+ account_email=account_email,
803
  )
804
  emitted = True
805
  emitted_for_token = True
 
811
  return
812
  account_service.mark_image_result(token, True)
813
  break
814
+ except ImagePollTimeoutError as exc:
815
+ if account_email and not getattr(exc, "account_email", ""):
816
+ exc.account_email = account_email
817
  raise
818
+ except ImageGenerationError as exc:
819
  account_service.mark_image_result(token, False)
820
+ if account_email and not getattr(exc, "account_email", ""):
821
+ exc.account_email = account_email
822
+ logger.warning({
823
+ "event": "image_stream_generation_error",
824
+ "request_token": token,
825
+ "account_email": account_email,
826
+ "error": str(exc),
827
+ })
828
  raise
829
  except Exception as exc:
830
  account_service.mark_image_result(token, False)
831
  last_error = str(exc)
832
+ logger.warning({
833
+ "event": "image_stream_fail",
834
+ "request_token": token,
835
+ "account_email": account_email,
836
+ "error": last_error,
837
+ })
838
  if not emitted_for_token and is_token_invalid_error(last_error):
839
  refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
840
  if refreshed_token and refreshed_token != token:
 
842
  continue
843
  account_service.remove_invalid_token(token, "image_stream")
844
  continue
845
+ raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email) from exc
846
 
847
  if not emitted:
848
  if not last_error:
 
860
  data: list[dict[str, Any]] = []
861
  message = ""
862
  progress_parts: list[str] = []
863
+ account_email = ""
864
  for output in outputs:
865
  created = created or output.created
866
+ if output.account_email and not account_email:
867
+ account_email = output.account_email
868
  if output.kind == "progress" and output.text:
869
  progress_parts.append(output.text)
870
  elif output.kind == "message":
 
877
  text = message or "".join(progress_parts).strip()
878
  if text:
879
  result["message"] = text
880
+ if account_email:
881
+ result["_account_email"] = account_email
882
  return result
test/test_codex_4k.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Test script: directly call https://chatgpt.com/backend-api/codex/responses to generate one 2K image.
3
+ # Only edit ACCESS_TOKEN, then run: python codex_responses_image_test.py
4
+ # Fixed request parameters:
5
+ # prompt: A highly detailed square 2K image of a quiet futuristic library at sunrise
6
+ # responses model: gpt-5.5
7
+ # image model: gpt-image-2
8
+ # size: 2048x2048
9
+ # quality: auto
10
+ # output_format: png
11
+ # output file: codex_4k.png
12
+
13
+ import base64
14
+ import json
15
+ import time
16
+ import urllib.request
17
+
18
+ ACCESS_TOKEN = ""
19
+
20
+
21
+ def parse_events(raw):
22
+ ctype, text = raw.headers.get("content-type", ""), raw.read().decode("utf-8", "replace")
23
+ if "application/json" in ctype:
24
+ return [json.loads(text)]
25
+ events, lines = [], []
26
+ for line in text.splitlines() + [""]:
27
+ if not line:
28
+ if lines:
29
+ data = "\n".join(lines).strip()
30
+ if data and data != "[DONE]":
31
+ events.append(json.loads(data))
32
+ lines = []
33
+ elif line.startswith("data:"):
34
+ lines.append(line[5:].lstrip())
35
+ return events
36
+
37
+
38
+ def find_images(value):
39
+ if isinstance(value, dict):
40
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
41
+ result = value["result"].strip()
42
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
43
+ return [image for item in value.values() for image in find_images(item)]
44
+ if isinstance(value, list):
45
+ return [image for item in value for image in find_images(item)]
46
+ return []
47
+
48
+
49
+ def main():
50
+ start_time = time.time()
51
+ body = {
52
+ "model": "gpt-5.5",
53
+ "instructions": "Use the image_generation tool to create exactly one image for the user's request. Return the generated image result.",
54
+ "store": False,
55
+ "input": [{"role": "user", "content": [{"type": "input_text", "text": "A highly detailed square 2K image of a quiet futuristic library at sunrise"}]}],
56
+ "tools": [{
57
+ "type": "image_generation",
58
+ "model": "gpt-image-2",
59
+ "action": "generate",
60
+ "size": "3840x2160",
61
+ "quality": "auto",
62
+ "output_format": "png"
63
+ }],
64
+ "tool_choice": {"type": "image_generation"},
65
+ "stream": True
66
+ }
67
+ headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"}
68
+
69
+ req = urllib.request.Request("https://chatgpt.com/backend-api/codex/responses", json.dumps(body).encode(), headers, method="POST")
70
+ try:
71
+ images = find_images(parse_events(urllib.request.urlopen(req, timeout=1200)))
72
+ except urllib.error.HTTPError as error:
73
+ raise SystemExit(f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:1000]}")
74
+
75
+ if not images:
76
+ raise SystemExit("No image result found in response")
77
+
78
+ with open("codex_4k.png", "wb") as file:
79
+ file.write(base64.b64decode(images[0]))
80
+ print("saved codex_4k.png")
81
+ end_time = time.time()
82
+ print(f"total time: {end_time - start_time:.2f} seconds")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()
web/src/hooks/use-version-check.ts CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useCallback, useEffect, useMemo, useState } from "react";
4
  import { toast } from "sonner";
5
 
6
  import webConfig from "@/constants/common-env";
@@ -40,11 +40,6 @@ export function useVersionCheck() {
40
  const [open, setOpen] = useState(false);
41
  const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
42
 
43
- const checkLatestVersion = useCallback(async () => {
44
- const response = await fetch(latestVersionUrl);
45
- if (response.ok) setLatestVersion((await response.text()).trim() || currentVersion);
46
- }, [currentVersion]);
47
-
48
  const checkLatestRelease = useCallback(
49
  async (showMessage = false) => {
50
  setChecking(true);
@@ -72,10 +67,6 @@ export function useVersionCheck() {
72
  [currentVersion, localReleases],
73
  );
74
 
75
- useEffect(() => {
76
- void checkLatestVersion();
77
- }, [checkLatestVersion]);
78
-
79
  const openReleaseModal = () => {
80
  setOpen(true);
81
  void checkLatestRelease();
 
1
  "use client";
2
 
3
+ import { useCallback, useMemo, useState } from "react";
4
  import { toast } from "sonner";
5
 
6
  import webConfig from "@/constants/common-env";
 
40
  const [open, setOpen] = useState(false);
41
  const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
42
 
 
 
 
 
 
43
  const checkLatestRelease = useCallback(
44
  async (showMessage = false) => {
45
  setChecking(true);
 
67
  [currentVersion, localReleases],
68
  );
69
 
 
 
 
 
70
  const openReleaseModal = () => {
71
  setOpen(true);
72
  void checkLatestRelease();