HouYunFei commited on
Commit ·
e8bdc10
1
Parent(s): a4564e7
feat(protocol): 添加图像生成服务的账户邮箱追踪功能
Browse files- 在 ImageGenerationError 和 ImageOutput 类中添加 account_email 字段
- 实现 codex 图像响应的错误解析和事件统计功能
- 集成账户邮箱到图像生成流处理和日志记录流程
- 添加内部
- README.md +1 -8
- services/image_task_service.py +11 -1
- services/log_service.py +52 -9
- services/openai_backend_api.py +177 -70
- services/protocol/conversation.py +93 -6
- test/test_codex_4k.py +86 -0
- web/src/hooks/use-version-check.ts +1 -10
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 226 |
except Exception as exc:
|
| 227 |
failed = True
|
| 228 |
-
self.log(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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":
|
| 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":
|
| 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 |
-
|
| 561 |
self.base_url + path,
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
stream=True,
|
| 566 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
try:
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
if "
|
| 580 |
-
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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();
|