Spaces:
Paused
Paused
| import base64 | |
| import json | |
| import mimetypes | |
| import os | |
| import random | |
| import re | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from concurrent.futures import ThreadPoolExecutor | |
| from dataclasses import dataclass | |
| from io import BytesIO | |
| from pathlib import Path | |
| from collections.abc import Callable | |
| from typing import Any, Dict, Iterator, Optional | |
| from urllib.parse import unquote, urlparse | |
| from curl_cffi import requests | |
| from PIL import Image | |
| from services.account_service import account_service | |
| from services.config import config | |
| from services.proxy_service import proxy_settings | |
| from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model | |
| from utils.log import logger | |
| from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources | |
| from utils.turnstile import solve_turnstile_token | |
| class InvalidAccessTokenError(RuntimeError): | |
| pass | |
| class ImagePollTimeoutError(RuntimeError): | |
| pass | |
| class ImageContentPolicyError(RuntimeError): | |
| """Raised when image generation is blocked by content policy moderation.""" | |
| pass | |
| class ChatRequirements: | |
| """保存一次对话请求所需的 sentinel token。""" | |
| token: str | |
| proof_token: str = "" | |
| turnstile_token: str = "" | |
| so_token: str = "" | |
| raw_finalize: Optional[Dict[str, Any]] = None | |
| DEFAULT_CLIENT_VERSION = "prod-a194cd50d4416d3c0b47c740f206b12ce60f5887" | |
| DEFAULT_CLIENT_BUILD_NUMBER = "6708908" | |
| DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js" | |
| CODEX_IMAGE_MODEL = "codex-gpt-image-2" | |
| CODEX_RESPONSES_MODEL = "gpt-5.5" | |
| SEARCH_MODEL = "gpt-5-5" | |
| SEARCH_TIMEOUT_SECS = 300.0 | |
| SEARCH_POLL_INTERVAL_SECS = 3.0 | |
| SEARCH_DONE_STATUS = {"finished_successfully", "finished_partial_completion"} | |
| SEARCH_CONVERSATION_ID_RE = re.compile(r'"conversation_id"\s*:\s*"([^"]+)"') | |
| SEARCH_URL_RE = re.compile(r"https?://[^\s\"'<>))\]}]+") | |
| EDITABLE_FILE_MODEL = "gpt-5-5-thinking" | |
| EDITABLE_FILE_THINKING_EFFORT = "extended" | |
| EDITABLE_FILE_TIMEOUT_SECS = 1200.0 | |
| EDITABLE_FILE_POLL_INTERVAL_SECS = 5.0 | |
| EDITABLE_FILE_CLIENT_VERSION = "prod-bede35f9dcd856d080e012478f0c1031faa2588e" | |
| EDITABLE_FILE_CLIENT_BUILD_NUMBER = "6631702" | |
| EDITABLE_FILE_PSD_OUTPUT_DIR = "data/files/psd" | |
| EDITABLE_FILE_PPT_OUTPUT_DIR = "data/files/ppt" | |
| EDITABLE_FILE_PPT_PROMPT = """我需要你根据用户的需求,来制作一个可以编辑的PPT,你可以使用Agent来做,你不要再继续询问用户问题,内容风格、版式、配色、内容结构和页面信息你可以自行补充并直接执行。整体的流程如下: | |
| 1. 用生图的方式,帮我生成一个精美的产品介绍ppt,5-6个页面 | |
| 2. 帮我把以上涉及到的所有图像和形状素材拆分成单独png,每个素材单独一张图片,不要有遗漏,让我可以直接在ppt里拼接素材还原,不要文字 | |
| 3. 利用以上所有图片和形状素材,帮我还原你第一次生成的展示ppt,我需要是可编辑的ppt格式,主要部分需要你单独还原插入,文字需要可以编辑 | |
| 最后只需要给我生成一个PPT文件,以及生成中遇到的各种素材压缩包zip文件就行。""" | |
| EDITABLE_FILE_PSD_PROMPT = "帮我生成这个图像,把这张海报分成若干图像,包括背景图,每个元素不要改位置,这样子我可以直接在 平时里无需拖动,底色为白色,不要伪透明底。再帮我将以上拆分的图像拼合成一个psd文件,去除白色底,不要改变每个图层的相应位置,保留每个元素所在图层的相应位置,保留每个元素的图层,最后只需要给我输出psd文件,以及每个图层的zip文件" | |
| EDITABLE_ASSET_POINTER_RE = re.compile(r"(?:file-service|sediment)://([A-Za-z0-9_-]+)") | |
| EDITABLE_ZIP_MIME_TYPES = {"application/zip", "application/x-zip-compressed"} | |
| EDITABLE_PSD_MIME_TYPES = {"image/vnd.adobe.photoshop", "application/vnd.adobe.photoshop"} | |
| EDITABLE_PPT_MIME_TYPES = { | |
| "application/vnd.openxmlformats-officedocument.presentationml.presentation", | |
| "application/vnd.ms-powerpoint", | |
| } | |
| EDITABLE_PSD_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:psd|zip))", re.IGNORECASE) | |
| EDITABLE_PPT_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:pptx?|zip))", re.IGNORECASE) | |
| FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)") | |
| FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b") | |
| # 真正的图片文件 ID 格式:file_00000000 + 24位十六进制字符(共32字符) | |
| REAL_IMAGE_FILE_ID_RE = re.compile(r"\bfile_00000000[a-f0-9]{24}\b") | |
| SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)") | |
| IMAGE_POLL_SETTLE_SECS = 2.0 | |
| CODEX_RESPONSES_INSTRUCTIONS = ( | |
| "Use the image_generation tool to create exactly one image for the user's request. " | |
| "Return the generated image result." | |
| ) | |
| # 内容政策违规错误关键词(上游拒绝生成图片的各种表述) | |
| _CONTENT_POLICY_KEYWORDS = ( | |
| # 明确的内容政策违规 | |
| "内容政策", "防护限制", "违反", "moderation", "policy", "blocked", | |
| # 拒绝生成类 | |
| "不能生成", "无法生成", "不能帮助", "无法帮助", | |
| # 敏感内容类 | |
| "裸体", "裸露", "色情", "性内容", "未成年", | |
| # 通用拒绝 | |
| "抱歉,我不能", | |
| ) | |
| def _is_content_policy_error(error_msg: str) -> bool: | |
| """检查错误消息是否为内容政策违规。""" | |
| if not error_msg: | |
| return False | |
| msg_lower = error_msg.lower() | |
| return any(keyword in msg_lower for keyword in _CONTENT_POLICY_KEYWORDS) | |
| class EditableFileArtifact: | |
| attachment_id: str = "" | |
| file_id: str = "" | |
| name: str = "" | |
| mime_type: str = "" | |
| create_time: float = 0.0 | |
| author_role: str = "" | |
| sandbox_path: str = "" | |
| message_id: str = "" | |
| class EditableFileExportResult: | |
| conversation_id: str | |
| primary_path: Path | |
| zip_path: Path | |
| class OpenAIBackendAPI: | |
| """ChatGPT Web 后端封装。 | |
| 说明: | |
| - 传入 `access_token` 时,聊天和模型列表都会走已登录链路 | |
| 例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation` | |
| - 不传 `access_token` 时,会走未登录链路 | |
| 例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation` | |
| - `stream_conversation()` 是底层统一流式入口 | |
| - 协议兼容转换放在 `services.protocol` | |
| """ | |
| def __init__(self, access_token: str = "") -> None: | |
| """初始化后端客户端。 | |
| 参数: | |
| - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。 | |
| """ | |
| self.base_url = "https://chatgpt.com" | |
| self.client_version = DEFAULT_CLIENT_VERSION | |
| self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER | |
| self.access_token = access_token | |
| self.account = account_service.get_account(self.access_token) if self.access_token else {} | |
| self.account = self.account if isinstance(self.account, dict) else {} | |
| self.fp = self._build_fp() | |
| self.user_agent = self.fp["user-agent"] | |
| self.device_id = self.fp["oai-device-id"] | |
| self.session_id = self.fp["oai-session-id"] | |
| self.pow_script_sources: list[str] = [] | |
| self.pow_data_build = "" | |
| self.progress_callback: Callable[[str], None] | None = None | |
| self.session = requests.Session(**proxy_settings.build_session_kwargs( | |
| account=self.account, | |
| impersonate=self.fp["impersonate"], | |
| verify=True, | |
| )) | |
| self.session.headers.update({ | |
| "User-Agent": self.user_agent, | |
| "Origin": self.base_url, | |
| "Referer": self.base_url + "/", | |
| "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7", | |
| "Cache-Control": "no-cache", | |
| "Pragma": "no-cache", | |
| "Priority": "u=1, i", | |
| "Sec-Ch-Ua": self.fp["sec-ch-ua"], | |
| "Sec-Ch-Ua-Arch": '"x86"', | |
| "Sec-Ch-Ua-Bitness": '"64"', | |
| "Sec-Ch-Ua-Full-Version": '"143.0.3650.96"', | |
| "Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"', | |
| "Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"], | |
| "Sec-Ch-Ua-Model": '""', | |
| "Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"], | |
| "Sec-Ch-Ua-Platform-Version": '"19.0.0"', | |
| "Sec-Fetch-Dest": "empty", | |
| "Sec-Fetch-Mode": "cors", | |
| "Sec-Fetch-Site": "same-origin", | |
| "OAI-Device-Id": self.device_id, | |
| "OAI-Session-Id": self.session_id, | |
| "OAI-Language": "zh-CN", | |
| "OAI-Client-Version": self.client_version, | |
| "OAI-Client-Build-Number": self.client_build_number, | |
| }) | |
| if self.access_token: | |
| self.session.headers["Authorization"] = f"Bearer {self.access_token}" | |
| def _build_fp(self) -> Dict[str, str]: | |
| account = self.account | |
| raw_fp = account.get("fp") | |
| fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {} | |
| for key in ( | |
| "user-agent", | |
| "impersonate", | |
| "oai-device-id", | |
| "oai-session-id", | |
| "sec-ch-ua", | |
| "sec-ch-ua-mobile", | |
| "sec-ch-ua-platform", | |
| ): | |
| value = str(account.get(key) or "").strip() | |
| if value: | |
| fp[key] = value | |
| fp.setdefault( | |
| "user-agent", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", | |
| ) | |
| fp.setdefault("impersonate", "edge101") | |
| fp.setdefault("oai-device-id", new_uuid()) | |
| fp.setdefault("oai-session-id", new_uuid()) | |
| fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"') | |
| fp.setdefault("sec-ch-ua-mobile", "?0") | |
| fp.setdefault("sec-ch-ua-platform", '"Windows"') | |
| return fp | |
| def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: | |
| """构造请求头,并补上 web 端要求的 target path/route。""" | |
| headers = dict(self.session.headers) | |
| headers["X-OpenAI-Target-Path"] = path | |
| headers["X-OpenAI-Target-Route"] = path | |
| if extra: | |
| headers.update(extra) | |
| return headers | |
| def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]: | |
| for item in limits_progress: | |
| if isinstance(item, dict) and item.get("feature_name") == "image_gen": | |
| return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False | |
| return 0, None, True | |
| def _raise_on_error(self, response: Any, path: str) -> None: | |
| if response.status_code == 401: | |
| raise InvalidAccessTokenError(f"token invalidated ({path})") | |
| raise RuntimeError(f"{path} failed: HTTP {response.status_code}") | |
| def _get_me(self) -> Dict[str, Any]: | |
| path = "/backend-api/me" | |
| response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20) | |
| if response.status_code != 200: | |
| self._raise_on_error(response, path) | |
| return response.json() | |
| def _get_conversation_init(self) -> Dict[str, Any]: | |
| path = "/backend-api/conversation/init" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Content-Type": "application/json"}), | |
| json={ | |
| "gizmo_id": None, | |
| "requested_default_model": None, | |
| "conversation_id": None, | |
| "timezone_offset_min": -480, | |
| }, | |
| timeout=20, | |
| ) | |
| if response.status_code != 200: | |
| self._raise_on_error(response, path) | |
| return response.json() | |
| def _get_default_account(self) -> Dict[str, Any]: | |
| path = "/backend-api/accounts/check/v4-2023-04-27" | |
| response = self.session.get(self.base_url + path + "?timezone_offset_min=-480", headers=self._headers(path), | |
| timeout=20) | |
| if response.status_code != 200: | |
| self._raise_on_error(response, path) | |
| payload = response.json() | |
| default_account = ((payload.get("accounts") or {}).get("default") or {}).get("account") or {} | |
| logger.debug({ | |
| "event": "backend_user_info_account_payload", | |
| "plan_type": default_account.get("plan_type"), | |
| "account_user_role": default_account.get("account_user_role"), | |
| "account_id": default_account.get("account_id"), | |
| "is_deactivated": default_account.get("is_deactivated"), | |
| "has_active_subscription": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("has_active_subscription"), | |
| "subscription_plan": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("subscription_plan"), | |
| }) | |
| return default_account | |
| def get_user_info(self) -> Dict[str, Any]: | |
| """获取当前 token 的账号信息。""" | |
| if not self.access_token: | |
| raise RuntimeError("access_token is required") | |
| executor = ThreadPoolExecutor(max_workers=3) | |
| try: | |
| me_future = executor.submit(self._get_me) | |
| init_future = executor.submit(self._get_conversation_init) | |
| account_future = executor.submit(self._get_default_account) | |
| me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result() | |
| except (KeyboardInterrupt, SystemExit): | |
| executor.shutdown(wait=False, cancel_futures=True) | |
| raise | |
| except BaseException: | |
| executor.shutdown(wait=False, cancel_futures=True) | |
| raise | |
| else: | |
| executor.shutdown(wait=True, cancel_futures=True) | |
| plan_type = str(default_account.get("plan_type") or "free") | |
| limits_progress = init_payload.get("limits_progress") | |
| limits_progress = limits_progress if isinstance(limits_progress, list) else [] | |
| quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress) | |
| result = { | |
| "email": me_payload.get("email"), | |
| "user_id": me_payload.get("id"), | |
| "type": plan_type, | |
| "quota": quota, | |
| "image_quota_unknown": image_quota_unknown, | |
| "limits_progress": limits_progress, | |
| "default_model_slug": init_payload.get("default_model_slug"), | |
| "restore_at": restore_at, | |
| "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"), | |
| } | |
| logger.debug({ | |
| "event": "backend_user_info_result", | |
| "email": result.get("email"), | |
| "user_id": result.get("user_id"), | |
| "type": result.get("type"), | |
| "quota": result.get("quota"), | |
| "image_quota_unknown": result.get("image_quota_unknown"), | |
| "default_model_slug": result.get("default_model_slug"), | |
| "restore_at": result.get("restore_at"), | |
| "status": result.get("status"), | |
| }) | |
| return result | |
| def _bootstrap_headers(self) -> Dict[str, str]: | |
| """构造首页预热请求头。""" | |
| return { | |
| "User-Agent": self.user_agent, | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", | |
| "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", | |
| "Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"], | |
| "Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"], | |
| "Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"], | |
| "Sec-Fetch-Dest": "document", | |
| "Sec-Fetch-Mode": "navigate", | |
| "Sec-Fetch-Site": "none", | |
| "Sec-Fetch-User": "?1", | |
| "Upgrade-Insecure-Requests": "1", | |
| } | |
| def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements: | |
| """把 sentinel 响应整理成后续对话需要的 token 集合。""" | |
| if (data.get("arkose") or {}).get("required"): | |
| raise RuntimeError("chat requirements requires arkose token, which is not implemented") | |
| proof_token = "" | |
| proof_info = data.get("proofofwork") or {} | |
| if proof_info.get("required"): | |
| proof_token = build_proof_token( | |
| proof_info.get("seed", ""), | |
| proof_info.get("difficulty", ""), | |
| self.user_agent, | |
| script_sources=self.pow_script_sources, | |
| data_build=self.pow_data_build, | |
| ) | |
| turnstile_token = "" | |
| turnstile_info = data.get("turnstile") or {} | |
| if turnstile_info.get("required") and turnstile_info.get("dx"): | |
| turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or "" | |
| return ChatRequirements( | |
| token=data.get("token", ""), | |
| proof_token=proof_token, | |
| turnstile_token=turnstile_token, | |
| so_token=data.get("so_token", ""), | |
| raw_finalize=data, | |
| ) | |
| def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]: | |
| """根据当前 requirements 构造对话 SSE 请求头。""" | |
| headers = { | |
| "Accept": "text/event-stream", | |
| "Content-Type": "application/json", | |
| "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token, | |
| } | |
| if requirements.proof_token: | |
| headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token | |
| if requirements.turnstile_token: | |
| headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token | |
| if requirements.so_token: | |
| headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token | |
| return self._headers(path, headers) | |
| def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]: | |
| """把标准 chat messages 转成 web conversation 所需的 messages。""" | |
| conversation_messages = [] | |
| for item in messages: | |
| role = item.get("role", "user") | |
| content = item.get("content", "") | |
| if isinstance(content, str): | |
| conversation_messages.append({ | |
| "id": new_uuid(), | |
| "author": {"role": role}, | |
| "content": {"content_type": "text", "parts": [content]}, | |
| }) | |
| continue | |
| if not isinstance(content, list): | |
| raise RuntimeError("only string or list message content is supported") | |
| text_parts: list[str] = [] | |
| image_inputs: list[tuple[bytes, str]] = [] | |
| for part in content: | |
| if not isinstance(part, dict): | |
| continue | |
| part_type = str(part.get("type") or "") | |
| if part_type == "text": | |
| text_parts.append(str(part.get("text") or "")) | |
| elif part_type == "image": | |
| data = part.get("data") | |
| mime = str(part.get("mime") or "image/png") | |
| if isinstance(data, (bytes, bytearray)): | |
| image_inputs.append((bytes(data), mime)) | |
| if not image_inputs: | |
| conversation_messages.append({ | |
| "id": new_uuid(), | |
| "author": {"role": role}, | |
| "content": {"content_type": "text", "parts": ["".join(text_parts)]}, | |
| }) | |
| continue | |
| if not self.access_token: | |
| raise RuntimeError("authenticated upstream account required for image input") | |
| uploaded: list[Dict[str, Any]] = [] | |
| for idx, (data, mime) in enumerate(image_inputs, start=1): | |
| ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png" | |
| extension = "jpg" if ext_part == "jpeg" else (ext_part or "png") | |
| b64 = base64.b64encode(data).decode("ascii") | |
| uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}")) | |
| parts: list[Any] = [] | |
| for ref in uploaded: | |
| parts.append({ | |
| "content_type": "image_asset_pointer", | |
| "asset_pointer": f"file-service://{ref['file_id']}", | |
| "width": ref["width"], | |
| "height": ref["height"], | |
| "size_bytes": ref["file_size"], | |
| }) | |
| text = "".join(text_parts) | |
| if text: | |
| parts.append(text) | |
| conversation_messages.append({ | |
| "id": new_uuid(), | |
| "author": {"role": role}, | |
| "content": {"content_type": "multimodal_text", "parts": parts}, | |
| "metadata": { | |
| "attachments": [{ | |
| "id": ref["file_id"], | |
| "mimeType": ref["mime_type"], | |
| "name": ref["file_name"], | |
| "size": ref["file_size"], | |
| "width": ref["width"], | |
| "height": ref["height"], | |
| } for ref in uploaded], | |
| }, | |
| }) | |
| return conversation_messages | |
| def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]: | |
| """把标准 messages 构造成 web 对话请求体。""" | |
| return { | |
| "action": "next", | |
| "messages": self._api_messages_to_conversation_messages(messages), | |
| "model": model, | |
| "parent_message_id": new_uuid(), | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "conversation_origin": None, | |
| "force_paragen": False, | |
| "force_paragen_model_slug": "", | |
| "force_rate_limit": False, | |
| "force_use_sse": True, | |
| "history_and_training_disabled": True, | |
| "reset_rate_limits": False, | |
| "suggestions": [], | |
| "supported_encodings": [], | |
| "system_hints": [], | |
| "timezone": timezone, | |
| "timezone_offset_min": -480, | |
| "variant_purpose": "comparison_implicit", | |
| "websocket_request_id": new_uuid(), | |
| "client_contextual_info": { | |
| "is_dark_mode": False, | |
| "time_since_loaded": 120, | |
| "page_height": 900, | |
| "page_width": 1400, | |
| "pixel_ratio": 2, | |
| "screen_height": 1440, | |
| "screen_width": 2560, | |
| }, | |
| } | |
| def _image_model_slug(self, model: str) -> str: | |
| """把标准图片模型名映射到底层 model slug。""" | |
| _, base_model = split_image_model(model) | |
| if not base_model: | |
| return "auto" | |
| if base_model == "gpt-image-2": | |
| return "gpt-5-3" | |
| if base_model == CODEX_IMAGE_MODEL: | |
| return base_model | |
| return "auto" | |
| def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \ | |
| Dict[str, str]: | |
| """构造图片链路请求头。""" | |
| headers = { | |
| "Content-Type": "application/json", | |
| "Accept": accept, | |
| "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token, | |
| } | |
| if requirements.proof_token: | |
| headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token | |
| if conduit_token: | |
| headers["X-Conduit-Token"] = conduit_token | |
| if accept == "text/event-stream": | |
| headers["X-Oai-Turn-Trace-Id"] = new_uuid() | |
| return self._headers(path, headers) | |
| def _codex_responses_headers(self) -> Dict[str, str]: | |
| return { | |
| "Authorization": f"Bearer {self.access_token}", | |
| "Content-Type": "application/json", | |
| } | |
| def _ensure_codex_source_account(self) -> None: | |
| account = account_service.get_account(self.access_token) | |
| source_type = str((account or {}).get("source_type") or "web").strip().lower() | |
| if source_type != "codex": | |
| raise RuntimeError("codex responses endpoint requires a codex source account") | |
| def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]: | |
| content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}] | |
| for image in images: | |
| payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}" | |
| content.append({"type": "input_image", "image_url": payload}) | |
| return [{"role": "user", "content": content}] | |
| def _codex_body_preview(body: Any, limit: int = 4000) -> str: | |
| if isinstance(body, (dict, list)): | |
| try: | |
| text = json.dumps(body, ensure_ascii=False) | |
| except Exception: | |
| text = repr(body) | |
| else: | |
| text = str(body or "") | |
| return text if len(text) <= limit else text[:limit] + "...[truncated]" | |
| def _codex_event_image_result_lengths(value: Any) -> list[int]: | |
| if isinstance(value, dict): | |
| lengths: list[int] = [] | |
| if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str): | |
| lengths.append(len(value["result"])) | |
| for item in value.values(): | |
| lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item)) | |
| return lengths | |
| if isinstance(value, list): | |
| lengths: list[int] = [] | |
| for item in value: | |
| lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item)) | |
| return lengths | |
| return [] | |
| def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]: | |
| summary: Dict[str, Any] = { | |
| "type": str(event.get("type") or ""), | |
| "keys": list(event.keys())[:30], | |
| } | |
| for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"): | |
| value = event.get(key) | |
| if isinstance(value, (str, int, float, bool)) or value is None: | |
| summary[key] = value | |
| for key in ("response", "item", "output"): | |
| value = event.get(key) | |
| if isinstance(value, dict): | |
| summary[f"{key}_type"] = value.get("type") | |
| summary[f"{key}_status"] = value.get("status") | |
| summary[f"{key}_keys"] = list(value.keys())[:30] | |
| elif isinstance(value, list): | |
| summary[f"{key}_len"] = len(value) | |
| summary[f"{key}_types"] = [ | |
| item.get("type") for item in value[:10] if isinstance(item, dict) | |
| ] | |
| error = event.get("error") | |
| if isinstance(error, dict): | |
| summary["error"] = { | |
| key: error.get(key) | |
| for key in ("type", "code", "message") | |
| if error.get(key) is not None | |
| } | |
| delta = event.get("delta") | |
| if isinstance(delta, str): | |
| summary["delta_len"] = len(delta) | |
| summary["delta_preview"] = delta[:200] | |
| result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event) | |
| if result_lengths: | |
| summary["image_result_lengths"] = result_lengths[:10] | |
| return summary | |
| def _log_codex_response_failure( | |
| self, | |
| path: str, | |
| status_code: int, | |
| headers: Any, | |
| payload: Dict[str, Any], | |
| body: Any, | |
| ) -> None: | |
| request_headers = self._codex_responses_headers() | |
| safe_request_headers = { | |
| key: value for key, value in request_headers.items() if key.lower() != "authorization" | |
| } | |
| response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {}) | |
| tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {} | |
| logger.warning({ | |
| "event": "codex_responses_http_error", | |
| "path": path, | |
| "status_code": status_code, | |
| "request": { | |
| "model": payload.get("model"), | |
| "tool_model": tool.get("model"), | |
| "tool_action": tool.get("action"), | |
| "size": tool.get("size"), | |
| "quality": tool.get("quality"), | |
| "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0), | |
| "prompt_preview": self._codex_body_preview( | |
| (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""), | |
| 500, | |
| ), | |
| "headers": safe_request_headers, | |
| }, | |
| "response": { | |
| "headers": response_headers, | |
| "body_preview": self._codex_body_preview(body), | |
| }, | |
| }) | |
| def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]: | |
| content_type = str(raw.headers.get("content-type") or "").lower() | |
| text = raw.read().decode("utf-8", "replace") | |
| status_code = getattr(raw, "status", None) | |
| parse_errors: list[str] = [] | |
| events: list[Dict[str, Any]] = [] | |
| if "application/json" in content_type: | |
| try: | |
| data = json.loads(text) | |
| if isinstance(data, dict): | |
| events.append(data) | |
| except Exception as exc: | |
| parse_errors.append(str(exc)) | |
| else: | |
| lines: list[str] = [] | |
| for line in text.splitlines() + [""]: | |
| if not line: | |
| if lines: | |
| payload_text = "\n".join(lines).strip() | |
| if payload_text and payload_text != "[DONE]": | |
| try: | |
| data = json.loads(payload_text) | |
| except Exception as exc: | |
| parse_errors.append(str(exc)) | |
| data = None | |
| if isinstance(data, dict): | |
| events.append(data) | |
| lines = [] | |
| elif line.startswith("data:"): | |
| lines.append(line[5:].lstrip()) | |
| event_types: Dict[str, int] = {} | |
| image_result_lengths: list[int] = [] | |
| for event in events: | |
| event_type = str(event.get("type") or "<missing>") | |
| event_types[event_type] = event_types.get(event_type, 0) + 1 | |
| image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event)) | |
| logger.info({ | |
| "event": "codex_responses_response_debug", | |
| "status_code": status_code, | |
| "content_type": content_type, | |
| "response_text_len": len(text), | |
| "event_count": len(events), | |
| "event_types": event_types, | |
| "image_result_lengths": image_result_lengths[:10], | |
| "parse_error_count": len(parse_errors), | |
| "parse_errors": parse_errors[:5], | |
| "event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]], | |
| "event_previews": [ | |
| OpenAIBackendAPI._codex_body_preview(event, 1500) | |
| for event in events[:10] | |
| ] if not image_result_lengths else [], | |
| "body_preview": text[:1000] if not events else "", | |
| }) | |
| for event in events: | |
| yield event | |
| def iter_codex_image_response_events( | |
| self, | |
| prompt: str, | |
| images: list[str] | None = None, | |
| size: str | None = None, | |
| quality: str = "auto", | |
| ) -> Iterator[Dict[str, Any]]: | |
| if not self.access_token: | |
| raise RuntimeError("access_token is required for codex image endpoints") | |
| self._ensure_codex_source_account() | |
| path = "/backend-api/codex/responses" | |
| payload = { | |
| "model": CODEX_RESPONSES_MODEL, | |
| "instructions": CODEX_RESPONSES_INSTRUCTIONS, | |
| "store": False, | |
| "input": self._codex_image_input(prompt, images or []), | |
| "tools": [{ | |
| "type": "image_generation", | |
| "model": "gpt-image-2", | |
| "action": "edit" if images else "generate", | |
| "size": str(size or "1024x1024"), | |
| "quality": str(quality or "auto"), | |
| "output_format": "png", | |
| }], | |
| "tool_choice": {"type": "image_generation"}, | |
| "stream": True, | |
| } | |
| request = urllib.request.Request( | |
| self.base_url + path, | |
| json.dumps(payload).encode(), | |
| self._codex_responses_headers(), | |
| method="POST", | |
| ) | |
| account = account_service.get_account(self.access_token) or {} | |
| token_payload = account_service._decode_jwt_payload(self.access_token) | |
| auth_claim = token_payload.get("https://api.openai.com/auth") | |
| auth_claim = auth_claim if isinstance(auth_claim, dict) else {} | |
| tool = payload["tools"][0] | |
| logger.info({ | |
| "event": "codex_responses_request_debug", | |
| "url": self.base_url + path, | |
| "transport": "urllib.request", | |
| "timeout_secs": 1200, | |
| "account_email": str(account.get("email") or "").strip(), | |
| "source_type": str(account.get("source_type") or "").strip(), | |
| "account_type": str(account.get("type") or "").strip(), | |
| "token_claims": { | |
| "jti": token_payload.get("jti"), | |
| "iat": token_payload.get("iat"), | |
| "exp": token_payload.get("exp"), | |
| "client_id": token_payload.get("client_id"), | |
| "chatgpt_account_id": auth_claim.get("chatgpt_account_id"), | |
| "chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"), | |
| "localhost": auth_claim.get("localhost"), | |
| }, | |
| "request": { | |
| "model": payload.get("model"), | |
| "tool_model": tool.get("model"), | |
| "tool_action": tool.get("action"), | |
| "size": tool.get("size"), | |
| "quality": tool.get("quality"), | |
| "output_format": tool.get("output_format"), | |
| "stream": payload.get("stream"), | |
| "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0), | |
| "prompt_preview": self._codex_body_preview( | |
| (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""), | |
| 500, | |
| ), | |
| }, | |
| "headers": { | |
| key: value for key, value in self._codex_responses_headers().items() | |
| if key.lower() != "authorization" | |
| }, | |
| }) | |
| try: | |
| with urllib.request.urlopen(request, timeout=1200) as raw: | |
| yield from self._iter_codex_response_events(raw) | |
| except urllib.error.HTTPError as error: | |
| body_text = error.read().decode("utf-8", "replace") | |
| body: Any = body_text | |
| try: | |
| body = json.loads(body_text) | |
| except Exception: | |
| pass | |
| self._log_codex_response_failure(path, error.code, error.headers, payload, body) | |
| retry_after_header = error.headers.get("Retry-After") if error.headers else None | |
| retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None | |
| raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error | |
| def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str: | |
| """为图片生成准备 conduit token。""" | |
| path = "/backend-api/f/conversation/prepare" | |
| payload = { | |
| "action": "next", | |
| "fork_from_shared_post": False, | |
| "parent_message_id": new_uuid(), | |
| "model": self._image_model_slug(model), | |
| "client_prepare_state": "success", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "system_hints": ["picture_v2"], | |
| "partial_query": { | |
| "id": new_uuid(), | |
| "author": {"role": "user"}, | |
| "content": {"content_type": "text", "parts": [prompt]}, | |
| }, | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "client_contextual_info": {"app_name": "chatgpt.com"}, | |
| } | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._image_headers(path, requirements), | |
| json=payload, | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| return response.json().get("conduit_token", "") | |
| def _decode_image_base64(self, image: str) -> bytes: | |
| """把 base64 图片字符串或本地路径解码成二进制。""" | |
| if ( | |
| image | |
| and len(image) < 512 | |
| and not image.startswith("data:") | |
| and "\n" not in image | |
| and "\r" not in image | |
| ): | |
| file_path = Path(os.path.expanduser(image)) | |
| if file_path.exists() and file_path.is_file(): | |
| return file_path.read_bytes() | |
| payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image | |
| return base64.b64decode(payload) | |
| def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]: | |
| """上传一张 base64 图片,返回底层文件元数据。""" | |
| data = self._decode_image_base64(image) | |
| if ( | |
| image | |
| and len(image) < 512 | |
| and not image.startswith("data:") | |
| and "\n" not in image | |
| and "\r" not in image | |
| ): | |
| candidate_path = Path(os.path.expanduser(image)) | |
| if candidate_path.exists() and candidate_path.is_file(): | |
| file_name = candidate_path.name | |
| image = Image.open(BytesIO(data)) | |
| width, height = image.size | |
| mime_type = Image.MIME.get(image.format, "image/png") | |
| path = "/backend-api/files" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}), | |
| json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width, | |
| "height": height}, | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| upload_meta = response.json() | |
| response = self.session.put( | |
| upload_meta["upload_url"], | |
| headers={ | |
| "Content-Type": mime_type, | |
| "x-ms-blob-type": "BlockBlob", | |
| "x-ms-version": "2020-04-08", | |
| "Origin": self.base_url, | |
| "Referer": self.base_url + "/", | |
| "User-Agent": self.user_agent, | |
| "Accept": "application/json, text/plain, */*", | |
| "Accept-Language": "en-US,en;q=0.8", | |
| }, | |
| data=data, | |
| timeout=120, | |
| ) | |
| ensure_ok(response, "image_upload") | |
| path = f"/backend-api/files/{upload_meta['file_id']}/uploaded" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}), | |
| data="{}", | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| return { | |
| "file_id": upload_meta["file_id"], | |
| "file_name": file_name, | |
| "file_size": len(data), | |
| "mime_type": mime_type, | |
| "width": width, | |
| "height": height, | |
| } | |
| def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str, | |
| references: Optional[list[Dict[str, Any]]] = None) -> requests.Response: | |
| """启动图片生成或编辑的 SSE 请求。""" | |
| references = references or [] | |
| parts = [{ | |
| "content_type": "image_asset_pointer", | |
| "asset_pointer": f"file-service://{item['file_id']}", | |
| "width": item["width"], | |
| "height": item["height"], | |
| "size_bytes": item["file_size"], | |
| } for item in references] | |
| parts.append(prompt) | |
| content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text", | |
| "parts": [prompt]} | |
| metadata = { | |
| "developer_mode_connector_ids": [], | |
| "selected_github_repos": [], | |
| "selected_all_github_repos": False, | |
| "system_hints": ["picture_v2"], | |
| "serialization_metadata": {"custom_symbol_offsets": []}, | |
| } | |
| if references: | |
| metadata["attachments"] = [{ | |
| "id": item["file_id"], | |
| "mimeType": item["mime_type"], | |
| "name": item["file_name"], | |
| "size": item["file_size"], | |
| "width": item["width"], | |
| "height": item["height"], | |
| } for item in references] | |
| payload = { | |
| "action": "next", | |
| "messages": [{ | |
| "id": new_uuid(), | |
| "author": {"role": "user"}, | |
| "create_time": time.time(), | |
| "content": content, | |
| "metadata": metadata, | |
| }], | |
| "parent_message_id": new_uuid(), | |
| "model": self._image_model_slug(model), | |
| "client_prepare_state": "sent", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "enable_message_followups": True, | |
| "system_hints": ["picture_v2"], | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "client_contextual_info": { | |
| "is_dark_mode": False, | |
| "time_since_loaded": 1200, | |
| "page_height": 1072, | |
| "page_width": 1724, | |
| "pixel_ratio": 1.2, | |
| "screen_height": 1440, | |
| "screen_width": 2560, | |
| "app_name": "chatgpt.com", | |
| }, | |
| "paragen_cot_summary_display_override": "allow", | |
| "force_parallel_switch": "auto", | |
| } | |
| path = "/backend-api/f/conversation" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"), | |
| json=payload, | |
| timeout=300, | |
| stream=True, | |
| ) | |
| ensure_ok(response, path) | |
| return response | |
| def _get_conversation(self, conversation_id: str) -> Dict[str, Any]: | |
| """获取完整 conversation 详情。""" | |
| path = f"/backend-api/conversation/{conversation_id}" | |
| response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), | |
| timeout=60) | |
| ensure_ok(response, path) | |
| return response.json() | |
| def _list_recent_conversations(self, limit: int = 5, timeout_secs: float = 10.0) -> list[Dict[str, Any]]: | |
| """列出最近的对话列表,按更新时间倒序。 | |
| 当 SSE 流太短导致 conversation_id 丢失时,可以通过此方法 | |
| 查找最近创建的对话来恢复 conversation_id。 | |
| """ | |
| path = f"/backend-api/conversations?offset=0&limit={limit}&order=updated&conversation_filter=all" | |
| try: | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "application/json"}), | |
| timeout=timeout_secs, | |
| ) | |
| ensure_ok(response, path) | |
| data = response.json() | |
| return data.get("items") or data.get("conversations") or [] | |
| except Exception as exc: | |
| logger.debug({"event": "list_conversations_failed", "error": str(exc)}) | |
| return [] | |
| def find_conversation_by_prompt(self, prompt: str, started_at: float, timeout_secs: float = 10.0) -> str: | |
| """根据 prompt 和开始时间,从最近对话列表中查找匹配的 conversation_id。 | |
| 当 SSE 流太短导致 conversation_id 丢失时,使用此方法恢复。 | |
| 通过对比 prompt 关键词和时间戳来匹配最可能的对话。 | |
| 参数: | |
| prompt: 用户输入的 prompt 文本 | |
| started_at: 请求开始的时间戳(epoch seconds) | |
| timeout_secs: 请求超时秒数 | |
| 返回: | |
| 匹配的 conversation_id,如果未找到返回空字符串 | |
| """ | |
| items = self._list_recent_conversations(limit=10, timeout_secs=timeout_secs) | |
| if not items: | |
| return "" | |
| # 筛选在 started_at 之前或附近创建的对话(最多往前 5 分钟) | |
| # ChatGPT 的 updated_at 通常晚于实际请求时间 | |
| prompt_lower = str(prompt or "").lower().strip() | |
| best_match = "" | |
| best_score = 0.0 | |
| for item in items: | |
| # item 可能是完整的 conversation 对象或摘要 | |
| conv_id = str(item.get("id") or item.get("conversation_id") or "") | |
| if not conv_id: | |
| continue | |
| # 检查时间范围:对话的 updated_at 应该在请求开始时间之后(或附近) | |
| updated_at = float(item.get("update_time") or item.get("updated_at") or 0) | |
| if updated_at and started_at and (updated_at < started_at - 30 or updated_at > started_at + 600): | |
| continue | |
| # 匹配 prompt 关键词 | |
| title = str(item.get("title") or "").lower() | |
| # 计算匹配分数 | |
| score = 0.0 | |
| if prompt_lower and title: | |
| # 简单的关键词匹配 | |
| prompt_words = set(prompt_lower.split()) | |
| title_words = set(title.split()) | |
| common = prompt_words & title_words | |
| if common: | |
| score = len(common) / max(len(prompt_words), 1) | |
| # 图生图通常标题为 "Image" 开头 | |
| if title.startswith("image"): | |
| score += 0.3 | |
| if score > best_score: | |
| best_score = score | |
| best_match = conv_id | |
| if best_match and best_score > 0.1: | |
| logger.info({ | |
| "event": "conversation_prompt_match_found", | |
| "conversation_id": best_match, | |
| "match_score": round(best_score, 2), | |
| }) | |
| return best_match | |
| # 如果没有标题匹配,返回最新的对话(时间最近的) | |
| for item in items: | |
| conv_id = str(item.get("id") or item.get("conversation_id") or "") | |
| updated_at = float(item.get("update_time") or item.get("updated_at") or 0) | |
| if conv_id and updated_at and started_at and updated_at >= started_at - 30: | |
| logger.info({ | |
| "event": "conversation_latest_match", | |
| "conversation_id": conv_id, | |
| "updated_at": updated_at, | |
| }) | |
| return conv_id | |
| return "" | |
| def _editable_prompt(fixed_prompt: str, user_prompt_text: str) -> str: | |
| extra = str(user_prompt_text or "").strip() | |
| return fixed_prompt if not extra else fixed_prompt + "\n\n以下是用户补充需求,请直接结合执行:\n" + extra | |
| def export_ppt_zip( | |
| self, | |
| base64_images: list[str] | None, | |
| prompt: str, | |
| output_dir: str | Path = EDITABLE_FILE_PPT_OUTPUT_DIR, | |
| timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS, | |
| poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS, | |
| ) -> EditableFileExportResult: | |
| return self._export_editable_file_zip( | |
| base64_images or [], | |
| self._editable_prompt(EDITABLE_FILE_PPT_PROMPT, prompt), | |
| output_dir, | |
| primary_label="ppt", | |
| primary_suffixes=(".ppt", ".pptx"), | |
| primary_mime_types=EDITABLE_PPT_MIME_TYPES, | |
| primary_mime_keywords=("presentationml.presentation", "ms-powerpoint"), | |
| primary_default_extension=".pptx", | |
| export_file_re=EDITABLE_PPT_EXPORT_FILE_RE, | |
| timeout_secs=timeout_secs, | |
| poll_interval_secs=poll_interval_secs, | |
| ) | |
| def export_psd_zip( | |
| self, | |
| base64_images: list[str], | |
| prompt: str, | |
| output_dir: str | Path = EDITABLE_FILE_PSD_OUTPUT_DIR, | |
| timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS, | |
| poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS, | |
| ) -> EditableFileExportResult: | |
| if not base64_images: | |
| raise ValueError("base64_images is empty") | |
| return self._export_editable_file_zip( | |
| base64_images, | |
| self._editable_prompt(EDITABLE_FILE_PSD_PROMPT, prompt), | |
| output_dir, | |
| primary_label="psd", | |
| primary_suffixes=(".psd",), | |
| primary_mime_types=EDITABLE_PSD_MIME_TYPES, | |
| primary_mime_keywords=("photoshop",), | |
| primary_default_extension=".psd", | |
| export_file_re=EDITABLE_PSD_EXPORT_FILE_RE, | |
| timeout_secs=timeout_secs, | |
| poll_interval_secs=poll_interval_secs, | |
| ) | |
| def _export_editable_file_zip( | |
| self, | |
| base64_images: list[str], | |
| prompt: str, | |
| output_dir: str | Path, | |
| *, | |
| primary_label: str, | |
| primary_suffixes: tuple[str, ...], | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| primary_default_extension: str, | |
| export_file_re: re.Pattern[str], | |
| timeout_secs: float, | |
| poll_interval_secs: float, | |
| ) -> EditableFileExportResult: | |
| if not self.access_token: | |
| raise RuntimeError("access_token is required for editable file export") | |
| self.client_version = EDITABLE_FILE_CLIENT_VERSION | |
| self.client_build_number = EDITABLE_FILE_CLIENT_BUILD_NUMBER | |
| self.session.headers["OAI-Client-Version"] = EDITABLE_FILE_CLIENT_VERSION | |
| self.session.headers["OAI-Client-Build-Number"] = EDITABLE_FILE_CLIENT_BUILD_NUMBER | |
| output_path = Path(output_dir).expanduser().resolve() | |
| output_path.mkdir(parents=True, exist_ok=True) | |
| uploaded = [self._upload_editable_base64_image(item, index) for index, item in enumerate(base64_images, start=1)] | |
| conduit_token = self._prepare_editable_conversation(prompt, [item["mime_type"] for item in uploaded]) | |
| conversation_id = self._run_editable_conversation(prompt, uploaded, conduit_token) | |
| artifacts = self._wait_editable_output_artifacts( | |
| conversation_id, | |
| primary_label, | |
| primary_suffixes, | |
| primary_mime_types, | |
| primary_mime_keywords, | |
| export_file_re, | |
| timeout_secs, | |
| poll_interval_secs, | |
| ) | |
| downloaded = [self._download_editable_artifact( | |
| conversation_id, | |
| item, | |
| output_path, | |
| primary_mime_types, | |
| primary_mime_keywords, | |
| primary_default_extension, | |
| ) for item in artifacts] | |
| primary_path = next((item for item in downloaded if item.suffix.lower() in primary_suffixes), None) | |
| zip_path = next((item for item in downloaded if item.suffix.lower() == ".zip"), None) | |
| if not primary_path or not zip_path: | |
| raise RuntimeError(f"download finished but did not get both {primary_label} and zip files: {downloaded}") | |
| return EditableFileExportResult(conversation_id=conversation_id, primary_path=primary_path, zip_path=zip_path) | |
| def _upload_editable_base64_image(self, base64_image: str, index: int) -> Dict[str, Any]: | |
| data, file_name, mime_type, width, height = self._decode_editable_base64_image(base64_image, index) | |
| path = "/backend-api/files" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}), | |
| json={ | |
| "file_name": file_name, | |
| "file_size": len(data), | |
| "use_case": "multimodal", | |
| "timezone_offset_min": -480, | |
| "reset_rate_limits": False, | |
| "store_in_library": True, | |
| "library_persistence_mode": "opportunistic", | |
| }, | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| payload = response.json() | |
| upload_url = str(payload.get("upload_url") or "") | |
| file_id = str(payload.get("file_id") or "") | |
| if not upload_url or not file_id: | |
| raise RuntimeError(f"invalid upload response: {payload}") | |
| response = self.session.put( | |
| upload_url, | |
| headers={ | |
| "Content-Type": mime_type, | |
| "x-ms-blob-type": "BlockBlob", | |
| "x-ms-version": "2020-04-08", | |
| "Origin": self.base_url, | |
| "Referer": self.base_url + "/", | |
| "User-Agent": self.user_agent, | |
| "Accept": "application/json, text/plain, */*", | |
| "Accept-Language": "en-US,en;q=0.8", | |
| }, | |
| data=data, | |
| timeout=120, | |
| ) | |
| ensure_ok(response, "image_upload") | |
| path = f"/backend-api/files/{file_id}/uploaded" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}), | |
| data="{}", | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| return { | |
| "file_id": file_id, | |
| "library_file_id": str(payload.get("library_file_id") or ""), | |
| "file_name": file_name, | |
| "file_size": len(data), | |
| "mime_type": mime_type, | |
| "width": width, | |
| "height": height, | |
| } | |
| def _decode_editable_base64_image(self, base64_image: str, index: int) -> tuple[bytes, str, str, int, int]: | |
| raw = str(base64_image or "").strip() | |
| if not raw: | |
| raise ValueError("base64 image is empty") | |
| mime_type = "" | |
| payload = raw | |
| match = re.match(r"^data:([^;]+);base64,(.*)$", raw, re.IGNORECASE | re.DOTALL) | |
| if match: | |
| mime_type = str(match.group(1) or "").strip().lower() | |
| payload = str(match.group(2) or "").strip() | |
| data = base64.b64decode(payload) | |
| image = Image.open(BytesIO(data)) | |
| image.load() | |
| width, height = image.size | |
| mime_type = Image.MIME.get(image.format, mime_type or "image/png") | |
| extension = mimetypes.guess_extension(mime_type) or ".png" | |
| return data, f"image_{index}{extension}", mime_type, width, height | |
| def _prepare_editable_conversation(self, prompt: str, attachment_mime_types: list[str]) -> str: | |
| path = "/backend-api/f/conversation/prepare" | |
| payload: Dict[str, Any] = { | |
| "action": "next", | |
| "fork_from_shared_post": False, | |
| "parent_message_id": "client-created-root", | |
| "model": EDITABLE_FILE_MODEL, | |
| "client_prepare_state": "success", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "system_hints": [], | |
| "partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}}, | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "client_contextual_info": {"app_name": "chatgpt.com"}, | |
| "thinking_effort": EDITABLE_FILE_THINKING_EFFORT, | |
| } | |
| if attachment_mime_types: | |
| payload["attachment_mime_types"] = attachment_mime_types | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}), | |
| json=payload, | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| conduit_token = str(response.json().get("conduit_token") or "") | |
| if not conduit_token: | |
| raise RuntimeError(f"missing conduit_token: {response.text}") | |
| return conduit_token | |
| def _run_editable_conversation(self, prompt: str, uploaded: list[Dict[str, Any]], conduit_token: str) -> str: | |
| self._bootstrap() | |
| requirements = self._get_chat_requirements() | |
| message: Dict[str, Any] = {"id": new_uuid(), "author": {"role": "user"}, "create_time": time.time()} | |
| if uploaded: | |
| parts = [{ | |
| "content_type": "image_asset_pointer", | |
| "asset_pointer": f"sediment://{item['file_id']}", | |
| "size_bytes": item["file_size"], | |
| "width": item["width"], | |
| "height": item["height"], | |
| } for item in uploaded] | |
| parts.append(prompt) | |
| message["content"] = {"content_type": "multimodal_text", "parts": parts} | |
| message["metadata"] = { | |
| "attachments": [{ | |
| "id": item["file_id"], | |
| "size": item["file_size"], | |
| "name": item["file_name"], | |
| "mime_type": item["mime_type"], | |
| "width": item["width"], | |
| "height": item["height"], | |
| "source": "library", | |
| "library_file_id": item["library_file_id"], | |
| "is_big_paste": False, | |
| } for item in uploaded], | |
| "developer_mode_connector_ids": [], | |
| "selected_sources": [], | |
| "selected_github_repos": [], | |
| "selected_all_github_repos": False, | |
| "serialization_metadata": {"custom_symbol_offsets": []}, | |
| } | |
| else: | |
| message["content"] = {"content_type": "text", "parts": [prompt]} | |
| path = "/backend-api/f/conversation" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"), | |
| json={ | |
| "action": "next", | |
| "messages": [message], | |
| "parent_message_id": "client-created-root", | |
| "model": EDITABLE_FILE_MODEL, | |
| "client_prepare_state": "sent", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "enable_message_followups": True, | |
| "system_hints": [], | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "client_contextual_info": { | |
| "is_dark_mode": False, | |
| "time_since_loaded": 401, | |
| "page_height": 1138, | |
| "page_width": 803, | |
| "pixel_ratio": 2, | |
| "screen_height": 1440, | |
| "screen_width": 2560, | |
| "app_name": "chatgpt.com", | |
| }, | |
| "paragen_cot_summary_display_override": "allow", | |
| "force_parallel_switch": "auto", | |
| "thinking_effort": EDITABLE_FILE_THINKING_EFFORT, | |
| }, | |
| timeout=300, | |
| stream=True, | |
| ) | |
| ensure_ok(response, path) | |
| conversation_id = "" | |
| try: | |
| for payload in iter_sse_payloads(response): | |
| if payload == "[DONE]": | |
| break | |
| conversation_id = conversation_id or self._find_editable_value(payload, "conversation_id") | |
| finally: | |
| response.close() | |
| if not conversation_id: | |
| raise RuntimeError("conversation_id not found in stream") | |
| return conversation_id | |
| def _wait_editable_output_artifacts( | |
| self, | |
| conversation_id: str, | |
| primary_label: str, | |
| primary_suffixes: tuple[str, ...], | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| export_file_re: re.Pattern[str], | |
| timeout_secs: float, | |
| poll_interval_secs: float, | |
| ) -> list[EditableFileArtifact]: | |
| deadline = time.time() + timeout_secs | |
| while time.time() < deadline: | |
| try: | |
| conversation = self._get_editable_conversation_detail(conversation_id) | |
| except UpstreamHTTPError as exc: | |
| if exc.status_code in {404, 409, 423, 429, 500, 502, 503, 504}: | |
| time.sleep(poll_interval_secs) | |
| continue | |
| raise | |
| targeted = self._pick_editable_target_artifacts( | |
| self._extract_editable_artifacts(conversation, export_file_re), | |
| primary_suffixes, | |
| primary_mime_types, | |
| primary_mime_keywords, | |
| ) | |
| if targeted: | |
| return targeted | |
| time.sleep(poll_interval_secs) | |
| raise RuntimeError(f"timed out waiting for {primary_label}/zip outputs") | |
| def _get_editable_conversation_detail(self, conversation_id: str) -> Dict[str, Any]: | |
| path = f"/backend-api/conversation/{conversation_id}" | |
| response = self.session.get(self.base_url + path, headers=self._editable_conversation_document_headers(path, conversation_id), timeout=60) | |
| ensure_ok(response, path) | |
| return response.json() | |
| def _editable_browser_headers(self, path: str, conversation_id: str) -> Dict[str, str]: | |
| headers = self._headers(path, {"Accept": "*/*"}) | |
| headers["Referer"] = f"{self.base_url}/c/{conversation_id}" | |
| return headers | |
| def _editable_conversation_document_headers(self, path: str, conversation_id: str) -> Dict[str, str]: | |
| headers = self._editable_browser_headers(path, conversation_id) | |
| headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}" | |
| return headers | |
| def _extract_editable_artifacts(self, conversation: Dict[str, Any], export_file_re: re.Pattern[str]) -> list[EditableFileArtifact]: | |
| artifacts: dict[str, EditableFileArtifact] = {} | |
| for node in sorted((conversation.get("mapping") or {}).values(), key=lambda item: float(((item or {}).get("message") or {}).get("create_time") or 0.0)): | |
| message = (node or {}).get("message") or {} | |
| message_id = str(message.get("id") or "") | |
| author_role = str(((message.get("author") or {}).get("role") or "")).strip() | |
| if author_role not in {"assistant", "tool"}: | |
| continue | |
| create_time = float(message.get("create_time") or 0.0) | |
| message_text = self._editable_message_text(message) | |
| for artifact in self._extract_editable_message_artifacts(message, message_id, author_role, create_time, export_file_re): | |
| key = artifact.attachment_id or artifact.file_id or artifact.name or artifact.sandbox_path | |
| if key: | |
| artifacts[key] = self._merge_editable_artifact(artifacts.get(key), artifact) | |
| for export_path in self._extract_editable_export_paths(message_text, export_file_re): | |
| inferred = EditableFileArtifact(name=Path(export_path).name, create_time=create_time, author_role=author_role, sandbox_path=export_path, message_id=message_id) | |
| artifacts[export_path] = self._merge_editable_artifact(artifacts.get(export_path), inferred) | |
| return sorted(artifacts.values(), key=lambda item: item.create_time) | |
| def _extract_editable_message_artifacts( | |
| self, | |
| message: Dict[str, Any], | |
| message_id: str, | |
| author_role: str, | |
| create_time: float, | |
| export_file_re: re.Pattern[str], | |
| ) -> list[EditableFileArtifact]: | |
| artifacts: list[EditableFileArtifact] = [] | |
| for item in (message.get("metadata") or {}).get("attachments") or []: | |
| artifact = self._editable_artifact_from_dict(item, message_id, author_role, create_time, export_file_re) | |
| if artifact: | |
| artifacts.append(artifact) | |
| for obj in self._walk_search_dicts(message): | |
| artifact = self._editable_artifact_from_dict(obj, message_id, author_role, create_time, export_file_re) | |
| if artifact: | |
| artifacts.append(artifact) | |
| return artifacts | |
| def _editable_artifact_from_dict( | |
| self, | |
| payload: Dict[str, Any], | |
| message_id: str, | |
| author_role: str, | |
| create_time: float, | |
| export_file_re: re.Pattern[str], | |
| ) -> EditableFileArtifact | None: | |
| if not ({"id", "file_id", "asset_pointer", "name", "file_name", "filename", "mime_type", "mimeType"} & set(payload.keys())): | |
| return None | |
| attachment_id = self._match_editable_file_id(str(payload.get("id") or "")) | |
| file_id = self._match_editable_file_id(str(payload.get("file_id") or "")) | |
| name = self._sanitize_editable_filename(str(payload.get("name") or payload.get("file_name") or payload.get("filename") or payload.get("title") or "").strip()) | |
| mime_type = self._clean_editable_mime_type(payload.get("mime_type") or payload.get("mimeType") or "") | |
| for asset_id in EDITABLE_ASSET_POINTER_RE.findall(str(payload.get("asset_pointer") or "")): | |
| attachment_id = attachment_id or asset_id | |
| file_id = file_id or asset_id | |
| if not attachment_id or not file_id: | |
| ids = self._extract_editable_file_ids(json.dumps(payload, ensure_ascii=False)) | |
| attachment_id = attachment_id or (ids[0] if ids else "") | |
| file_id = file_id or (ids[0] if ids else "") | |
| if not attachment_id and not file_id: | |
| return None | |
| return EditableFileArtifact( | |
| attachment_id=attachment_id, | |
| file_id=file_id, | |
| name=name, | |
| mime_type=mime_type, | |
| create_time=create_time, | |
| author_role=author_role, | |
| sandbox_path=(self._extract_editable_export_paths(payload, export_file_re) or [""])[0], | |
| message_id=message_id, | |
| ) | |
| def _pick_editable_target_artifacts( | |
| self, | |
| artifacts: list[EditableFileArtifact], | |
| primary_suffixes: tuple[str, ...], | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| ) -> list[EditableFileArtifact]: | |
| primary = next((item for item in reversed(artifacts) if self._looks_like_editable_primary(item, primary_suffixes, primary_mime_types, primary_mime_keywords)), None) | |
| zip_item = next((item for item in reversed(artifacts) if self._looks_like_editable_zip(item)), None) | |
| return [primary, zip_item] if primary and zip_item else [] | |
| def _download_editable_artifact( | |
| self, | |
| conversation_id: str, | |
| artifact: EditableFileArtifact, | |
| output_dir: Path, | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| primary_default_extension: str, | |
| ) -> Path: | |
| download_url = self._resolve_editable_download_url(conversation_id, artifact) | |
| if not download_url: | |
| raise RuntimeError(f"download url not found for artifact: {artifact}") | |
| response = self.session.get(download_url, timeout=300) | |
| ensure_ok(response, "artifact_download") | |
| content_type = self._clean_editable_mime_type(response.headers.get("Content-Type") or artifact.mime_type) | |
| file_name = self._resolve_editable_output_name(artifact, response.url, response.headers.get("Content-Disposition"), content_type, primary_mime_types, primary_mime_keywords, primary_default_extension) | |
| target_path = self._unique_editable_path(output_dir / file_name) | |
| target_path.write_bytes(response.content) | |
| return target_path | |
| def _resolve_editable_download_url(self, conversation_id: str, artifact: EditableFileArtifact) -> str: | |
| ids: list[str] = [] | |
| for item in (artifact.attachment_id, artifact.file_id): | |
| if item and item not in ids: | |
| ids.append(item) | |
| if artifact.sandbox_path and artifact.message_id: | |
| path = f"/backend-api/conversation/{conversation_id}/interpreter/download" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/interpreter/download"), | |
| params={"message_id": artifact.message_id, "sandbox_path": artifact.sandbox_path}, | |
| timeout=60, | |
| ) | |
| if 200 <= response.status_code < 300: | |
| url = self._download_url_from_response(response) | |
| if url: | |
| return url | |
| for attachment_id in ids: | |
| path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"), | |
| timeout=60, | |
| ) | |
| if 200 <= response.status_code < 300: | |
| url = self._download_url_from_response(response) | |
| if url: | |
| return url | |
| for file_id in ids: | |
| path = f"/backend-api/files/download/{file_id}" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"), | |
| params={"post_id": "", "inline": "false"}, | |
| timeout=60, | |
| ) | |
| if 200 <= response.status_code < 300: | |
| url = self._download_url_from_response(response) | |
| if url: | |
| return url | |
| for file_id in ids: | |
| path = f"/backend-api/files/{file_id}/download" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"), | |
| timeout=60, | |
| ) | |
| if 200 <= response.status_code < 300: | |
| url = self._download_url_from_response(response) | |
| if url: | |
| return url | |
| return "" | |
| def _editable_download_headers(self, path: str, conversation_id: str, route: str) -> Dict[str, str]: | |
| headers = self._editable_browser_headers(path, conversation_id) | |
| headers["X-OpenAI-Target-Route"] = route | |
| return headers | |
| def _download_url_from_response(response: Any) -> str: | |
| try: | |
| payload = response.json() | |
| except Exception: | |
| payload = {} | |
| return str(payload.get("download_url") or payload.get("url") or "") | |
| def _resolve_editable_output_name( | |
| self, | |
| artifact: EditableFileArtifact, | |
| final_url: str, | |
| content_disposition: str | None, | |
| content_type: str, | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| primary_default_extension: str, | |
| ) -> str: | |
| file_name = self._sanitize_editable_filename(artifact.name) | |
| if not file_name and artifact.sandbox_path: | |
| file_name = self._sanitize_editable_filename(Path(artifact.sandbox_path).name) | |
| if not file_name: | |
| file_name = self._sanitize_editable_filename(self._editable_filename_from_content_disposition(content_disposition or "")) | |
| if not file_name: | |
| file_name = self._sanitize_editable_filename(Path(urlparse(final_url).path).name) | |
| extension = self._editable_extension_from_mime_type(content_type, primary_mime_types, primary_mime_keywords, primary_default_extension) | |
| return f"artifact{extension}" if not file_name else (file_name if Path(file_name).suffix else file_name + extension) | |
| def _find_editable_value(self, payload: Any, key: str) -> str: | |
| if isinstance(payload, str): | |
| match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None | |
| if match: | |
| return match.group(1) | |
| try: | |
| payload = json.loads(payload) | |
| except json.JSONDecodeError: | |
| return "" | |
| if isinstance(payload, dict): | |
| value = payload.get(key) | |
| if isinstance(value, str) and value: | |
| return value | |
| return next((found for item in payload.values() if (found := self._find_editable_value(item, key))), "") | |
| if isinstance(payload, list): | |
| return next((found for item in payload if (found := self._find_editable_value(item, key))), "") | |
| return "" | |
| def _extract_editable_file_ids(self, text: str) -> list[str]: | |
| values: list[str] = [] | |
| for item in EDITABLE_ASSET_POINTER_RE.findall(text): | |
| if item not in values: | |
| values.append(item) | |
| for item in FILE_ID_RE.findall(text): | |
| if item not in values: | |
| values.append(item) | |
| return values | |
| def _match_editable_file_id(value: str) -> str: | |
| match = FILE_ID_RE.search(value) | |
| return match.group(1) if match else "" | |
| def _clean_editable_mime_type(value: Any) -> str: | |
| text = str(value or "").strip().lower() | |
| return text.split(";", 1)[0] if "/" in text else "" | |
| def _looks_like_editable_primary( | |
| self, | |
| artifact: EditableFileArtifact, | |
| primary_suffixes: tuple[str, ...], | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| ) -> bool: | |
| path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type | |
| return name.endswith(primary_suffixes) or path.endswith(primary_suffixes) or mime in primary_mime_types or any(keyword in mime for keyword in primary_mime_keywords) | |
| def _looks_like_editable_zip(artifact: EditableFileArtifact) -> bool: | |
| path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type | |
| return name.endswith(".zip") or path.endswith(".zip") or mime in EDITABLE_ZIP_MIME_TYPES or mime.endswith("/zip") | |
| def _editable_extension_from_mime_type( | |
| mime_type: str, | |
| primary_mime_types: set[str], | |
| primary_mime_keywords: tuple[str, ...], | |
| primary_default_extension: str, | |
| ) -> str: | |
| if mime_type in primary_mime_types or any(keyword in mime_type for keyword in primary_mime_keywords): | |
| return primary_default_extension | |
| if mime_type in EDITABLE_ZIP_MIME_TYPES or mime_type.endswith("/zip"): | |
| return ".zip" | |
| return mimetypes.guess_extension(mime_type) or "" | |
| def _editable_filename_from_content_disposition(content_disposition: str) -> str: | |
| extended_match = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition, re.IGNORECASE) | |
| if extended_match: | |
| return unquote(extended_match.group(1)).strip() | |
| plain_match = re.search(r'filename="([^"]+)"', content_disposition, re.IGNORECASE) | |
| return plain_match.group(1).strip() if plain_match else "" | |
| def _sanitize_editable_filename(value: str) -> str: | |
| return Path(str(value or "").strip()).name.replace("\x00", "").strip() | |
| def _unique_editable_path(path: Path) -> Path: | |
| if not path.exists(): | |
| return path | |
| for index in range(1, 1000): | |
| candidate = path.with_name(f"{path.stem}_{index}{path.suffix}") | |
| if not candidate.exists(): | |
| return candidate | |
| raise RuntimeError(f"failed to allocate output path for {path}") | |
| def _merge_editable_artifact(current: EditableFileArtifact | None, latest: EditableFileArtifact) -> EditableFileArtifact: | |
| if current is None: | |
| return latest | |
| return EditableFileArtifact( | |
| attachment_id=latest.attachment_id or current.attachment_id, | |
| file_id=latest.file_id or current.file_id, | |
| name=latest.name or current.name, | |
| mime_type=latest.mime_type or current.mime_type, | |
| create_time=max(current.create_time, latest.create_time), | |
| author_role=latest.author_role or current.author_role, | |
| sandbox_path=latest.sandbox_path or current.sandbox_path, | |
| message_id=latest.message_id or current.message_id, | |
| ) | |
| def _editable_message_text(message: Any) -> str: | |
| if not isinstance(message, dict): | |
| return "" | |
| content = message.get("content") or {} | |
| parts: list[str] = [] | |
| if isinstance(content, dict): | |
| if isinstance(content.get("text"), str): | |
| parts.append(content["text"]) | |
| for part in content.get("parts") or []: | |
| if isinstance(part, str): | |
| parts.append(part) | |
| elif isinstance(part, dict): | |
| parts.extend(str(part.get(key) or "") for key in ("text", "asset_pointer", "model_set_context") if part.get(key)) | |
| if isinstance(message.get("content"), str): | |
| parts.append(str(message["content"])) | |
| return "\n".join(part for part in parts if part).strip() | |
| def _extract_editable_export_paths(payload: Any, export_file_re: re.Pattern[str]) -> list[str]: | |
| if isinstance(payload, str): | |
| text = payload | |
| else: | |
| try: | |
| text = json.dumps(payload, ensure_ascii=False) | |
| except Exception: | |
| text = str(payload) | |
| values: list[str] = [] | |
| for item in export_file_re.findall(text): | |
| path = str(item or "").strip() | |
| if path and path not in values: | |
| values.append(path) | |
| return values | |
| def search(self, prompt: str, model: str = SEARCH_MODEL, timeout_secs: float = SEARCH_TIMEOUT_SECS, | |
| poll_interval_secs: float = SEARCH_POLL_INTERVAL_SECS) -> Dict[str, Any]: | |
| if not self.access_token: | |
| raise RuntimeError("access_token is required for search") | |
| conduit_token = self._prepare_search_conversation(prompt, model) | |
| self._bootstrap() | |
| conversation_id = self._run_search_conversation(prompt, conduit_token, model) | |
| return self._wait_search_result(conversation_id, timeout_secs, poll_interval_secs) | |
| def _prepare_search_conversation(self, prompt: str, model: str) -> str: | |
| path = "/backend-api/f/conversation/prepare" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}), | |
| json={ | |
| "action": "next", | |
| "fork_from_shared_post": False, | |
| "parent_message_id": "client-created-root", | |
| "model": model, | |
| "client_prepare_state": "success", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "system_hints": ["search"], | |
| "partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}}, | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "client_contextual_info": {"app_name": "chatgpt.com"}, | |
| }, | |
| timeout=60, | |
| ) | |
| ensure_ok(response, path) | |
| token = str(response.json().get("conduit_token") or "") | |
| if not token: | |
| raise RuntimeError("missing conduit_token") | |
| return token | |
| def _run_search_conversation(self, prompt: str, conduit_token: str, model: str) -> str: | |
| requirements = self._get_chat_requirements() | |
| path = "/backend-api/f/conversation" | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"), | |
| json={ | |
| "action": "next", | |
| "messages": [{ | |
| "id": new_uuid(), | |
| "author": {"role": "user"}, | |
| "create_time": time.time(), | |
| "content": {"content_type": "text", "parts": [prompt]}, | |
| "metadata": { | |
| "developer_mode_connector_ids": [], | |
| "selected_github_repos": [], | |
| "selected_all_github_repos": False, | |
| "system_hints": ["search"], | |
| "serialization_metadata": {"custom_symbol_offsets": []}, | |
| }, | |
| }], | |
| "parent_message_id": "client-created-root", | |
| "model": model, | |
| "client_prepare_state": "success", | |
| "timezone_offset_min": -480, | |
| "timezone": "Asia/Shanghai", | |
| "conversation_mode": {"kind": "primary_assistant"}, | |
| "enable_message_followups": True, | |
| "system_hints": [], | |
| "supports_buffering": True, | |
| "supported_encodings": ["v1"], | |
| "force_use_search": True, | |
| "client_reported_search_source": "conversation_composer_web_icon", | |
| "client_contextual_info": {"is_dark_mode": False, "time_since_loaded": 36, "page_height": 925, "page_width": 886, "pixel_ratio": 2, "screen_height": 1440, "screen_width": 2560, "app_name": "chatgpt.com"}, | |
| "paragen_cot_summary_display_override": "allow", | |
| "force_parallel_switch": "auto", | |
| }, | |
| timeout=300, | |
| stream=True, | |
| ) | |
| ensure_ok(response, path) | |
| conversation_id = "" | |
| try: | |
| for payload in iter_sse_payloads(response): | |
| conversation_id = conversation_id or self._find_search_value(payload, "conversation_id") | |
| if payload == "[DONE]": | |
| break | |
| finally: | |
| response.close() | |
| if not conversation_id: | |
| raise RuntimeError("conversation_id not found in stream") | |
| return conversation_id | |
| def _wait_search_result(self, conversation_id: str, timeout_secs: float, poll_interval_secs: float) -> Dict[str, Any]: | |
| deadline = time.time() + timeout_secs | |
| last_result: Dict[str, Any] | None = None | |
| last_answer = "" | |
| stable_hits = 0 | |
| while time.time() < deadline: | |
| try: | |
| last_result = self._extract_search_result(conversation_id, self._get_search_conversation(conversation_id)) | |
| except UpstreamHTTPError as exc: | |
| if exc.status_code not in {404, 409, 423, 429, 500, 502, 503, 504}: | |
| raise | |
| if last_result and last_result.get("answer"): | |
| if last_result.get("status") in SEARCH_DONE_STATUS: | |
| return last_result | |
| answer = str(last_result.get("answer") or "") | |
| stable_hits = stable_hits + 1 if answer == last_answer else 0 | |
| last_answer = answer | |
| if stable_hits >= 2: | |
| return last_result | |
| time.sleep(poll_interval_secs) | |
| if last_result: | |
| return last_result | |
| raise RuntimeError(f"timed out waiting for search result: {conversation_id}") | |
| def _get_search_conversation(self, conversation_id: str) -> Dict[str, Any]: | |
| path = f"/backend-api/conversation/{conversation_id}" | |
| headers = self._headers(path, {"Accept": "*/*"}) | |
| headers["Referer"] = f"{self.base_url}/c/{conversation_id}" | |
| headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}" | |
| response = self.session.get(self.base_url + path, headers=headers, timeout=60) | |
| ensure_ok(response, path) | |
| return response.json() | |
| def _extract_search_result(self, conversation_id: str, conversation: Dict[str, Any]) -> Dict[str, Any]: | |
| messages = [] | |
| for node in (conversation.get("mapping") or {}).values(): | |
| message = (node or {}).get("message") or {} | |
| if ((message.get("author") or {}).get("role") or "") == "assistant": | |
| messages.append(message) | |
| message = max(messages, key=lambda item: float(item.get("create_time") or 0.0)) if messages else {} | |
| metadata = message.get("metadata") if isinstance(message.get("metadata"), dict) else {} | |
| finish_details = metadata.get("finish_details") if isinstance(metadata.get("finish_details"), dict) else {} | |
| answer = self._search_message_text(message) | |
| sources = self._extract_search_sources(message) | |
| for url in SEARCH_URL_RE.findall(answer): | |
| url = self._clean_search_url(url) | |
| if url and all(item["url"] != url for item in sources): | |
| sources.append({"title": "", "url": url, "snippet": "", "source_type": ""}) | |
| return { | |
| "conversation_id": conversation_id, | |
| "status": str(finish_details.get("type") or metadata.get("status") or self._find_search_value(message, "status") or "").strip(), | |
| "answer": answer, | |
| "sources": sources, | |
| "assistant_message_id": str(message.get("id") or ""), | |
| "create_time": float(message.get("create_time") or 0.0), | |
| } | |
| def _extract_search_sources(self, payload: Any) -> list[Dict[str, str]]: | |
| sources: list[Dict[str, str]] = [] | |
| for obj in self._walk_search_dicts(payload): | |
| metadata = obj.get("metadata") if isinstance(obj.get("metadata"), dict) else {} | |
| url = self._clean_search_url(obj.get("url") or obj.get("link") or obj.get("source_url") or metadata.get("url")) | |
| if url and all(item["url"] != url for item in sources): | |
| sources.append({ | |
| "title": str(obj.get("title") or obj.get("name") or obj.get("source") or "").strip(), | |
| "url": url, | |
| "snippet": str(obj.get("snippet") or obj.get("text") or obj.get("description") or "").strip(), | |
| "source_type": str(obj.get("type") or obj.get("source_type") or "").strip(), | |
| }) | |
| return sources | |
| def _search_message_text(self, message: Any) -> str: | |
| content = message.get("content") if isinstance(message, dict) else {} | |
| parts = [] | |
| if isinstance(content, dict): | |
| if isinstance(content.get("text"), str): | |
| parts.append(content["text"]) | |
| for part in content.get("parts") or []: | |
| if isinstance(part, str): | |
| parts.append(part) | |
| elif isinstance(part, dict): | |
| parts.extend(str(part.get(key) or "") for key in ("text", "summary", "content") if part.get(key)) | |
| elif isinstance(content, str): | |
| parts.append(content) | |
| return "\n".join(part.strip() for part in parts if str(part).strip()).strip() | |
| def _find_search_value(self, payload: Any, key: str) -> str: | |
| if isinstance(payload, str): | |
| match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None | |
| if match: | |
| return match.group(1) | |
| try: | |
| payload = json.loads(payload) | |
| except json.JSONDecodeError: | |
| return "" | |
| if isinstance(payload, dict): | |
| value = payload.get(key) | |
| if isinstance(value, str) and value: | |
| return value | |
| return next((found for item in payload.values() if (found := self._find_search_value(item, key))), "") | |
| if isinstance(payload, list): | |
| return next((found for item in payload if (found := self._find_search_value(item, key))), "") | |
| return "" | |
| def _walk_search_dicts(self, payload: Any) -> list[Dict[str, Any]]: | |
| if isinstance(payload, dict): | |
| return [payload, *(item for value in payload.values() for item in self._walk_search_dicts(value))] | |
| if isinstance(payload, list): | |
| return [item for value in payload for item in self._walk_search_dicts(value)] | |
| return [] | |
| def _clean_search_url(self, value: Any) -> str: | |
| return str(value or "").strip().rstrip(".,;,。;") | |
| def _add_unique(values: list[str], candidates: list[str]) -> None: | |
| for candidate in candidates: | |
| if candidate and candidate not in values: | |
| values.append(candidate) | |
| def _extract_image_reference_ids(cls, payload: Any) -> tuple[list[str], list[str]]: | |
| file_ids: list[str] = [] | |
| sediment_ids: list[str] = [] | |
| def walk(value: Any) -> None: | |
| if isinstance(value, str): | |
| # 只提取真正的图片文件 ID(file_00000000... 格式)和 file-service:// URI | |
| cls._add_unique(file_ids, FILE_SERVICE_ID_RE.findall(value)) | |
| cls._add_unique(file_ids, REAL_IMAGE_FILE_ID_RE.findall(value)) | |
| cls._add_unique(sediment_ids, SEDIMENT_ID_RE.findall(value)) | |
| return | |
| if isinstance(value, dict): | |
| for item in value.values(): | |
| walk(item) | |
| return | |
| if isinstance(value, list): | |
| for item in value: | |
| walk(item) | |
| walk(payload) | |
| return file_ids, sediment_ids | |
| def _has_image_asset_pointer(cls, payload: Any) -> bool: | |
| if isinstance(payload, dict): | |
| if str(payload.get("content_type") or "") == "image_asset_pointer": | |
| return True | |
| asset_pointer = str(payload.get("asset_pointer") or "") | |
| if asset_pointer.startswith(("file-service://", "sediment://")): | |
| return True | |
| return any(cls._has_image_asset_pointer(item) for item in payload.values()) | |
| if isinstance(payload, list): | |
| return any(cls._has_image_asset_pointer(item) for item in payload) | |
| return False | |
| def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]: | |
| """从 conversation 明细里提取图片工具输出记录。""" | |
| mapping = data.get("mapping") or {} | |
| records = [] | |
| for message_id, node in mapping.items(): | |
| message = (node or {}).get("message") or {} | |
| author = message.get("author") or {} | |
| metadata = message.get("metadata") or {} | |
| content = message.get("content") or {} | |
| role = str(author.get("role") or "").strip().lower() | |
| if role not in {"tool", "assistant"}: | |
| continue | |
| is_image_gen = metadata.get("async_task_type") == "image_gen" | |
| has_asset_pointer = self._has_image_asset_pointer(content) or self._has_image_asset_pointer(metadata) | |
| if role == "assistant" and not (is_image_gen or has_asset_pointer): | |
| continue | |
| file_ids, sediment_ids = self._extract_image_reference_ids({"content": content, "metadata": metadata}) | |
| if not is_image_gen and not has_asset_pointer and not file_ids and not sediment_ids: | |
| continue | |
| records.append( | |
| {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids, | |
| "sediment_ids": sediment_ids}) | |
| return sorted(records, key=lambda item: item["create_time"]) | |
| def _find_content_policy_error_in_conversation(data: Dict[str, Any]) -> str: | |
| """从对话文档中查找内容政策违规错误消息。 | |
| 上游拒绝生成图片时,错误消息会出现在 assistant 消息的文本中。 | |
| 本方法遍历所有 assistant/tool 消息,检查是否包含内容政策违规关键词, | |
| 如果匹配则返回该消息文本(截断至 500 字符),否则返回空字符串。 | |
| """ | |
| mapping = data.get("mapping") or {} | |
| for node in mapping.values(): | |
| message = (node or {}).get("message") or {} | |
| author = message.get("author") or {} | |
| role = str(author.get("role") or "").strip().lower() | |
| if role not in {"assistant", "tool"}: | |
| continue | |
| content = message.get("content") or {} | |
| # 提取消息文本 | |
| text_parts: list[str] = [] | |
| if isinstance(content, dict): | |
| msg_parts = content.get("parts") or [] | |
| if isinstance(msg_parts, list): | |
| for part in msg_parts: | |
| if isinstance(part, str) and part.strip(): | |
| text_parts.append(part.strip()) | |
| text_field = str(content.get("text") or "") | |
| if text_field.strip(): | |
| text_parts.append(text_field.strip()) | |
| elif isinstance(content, str) and content.strip(): | |
| text_parts.append(content.strip()) | |
| msg_text = "\n".join(text_parts) | |
| if msg_text and _is_content_policy_error(msg_text): | |
| return msg_text[:500] | |
| return "" | |
| def _poll_image_results( | |
| self, | |
| conversation_id: str, | |
| timeout_secs: float = 120.0, | |
| initial_file_ids: list[str] | None = None, | |
| initial_sediment_ids: list[str] | None = None, | |
| ) -> tuple[list[str], list[str]]: | |
| """Poll the conversation document until image file ids appear or budget runs out. | |
| - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT | |
| image generation takes ~30s; polling immediately wastes requests and trips | |
| a transient 429 the upstream returns within ~200ms of the SSE stream | |
| closing (the conversation document is not yet committed). | |
| - Subsequent polls are image_poll_interval_secs apart (default 10s). | |
| - On upstream 429 / 5xx or network errors, backs off exponentially | |
| (capped at 16s, +jitter) honoring Retry-After when present. | |
| - All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError. | |
| """ | |
| start = time.time() | |
| attempt = 0 | |
| interval = float(config.image_poll_interval_secs) | |
| initial_wait = float(config.image_poll_initial_wait_secs) | |
| file_ids: list[str] = [] | |
| sediment_ids: list[str] = [] | |
| self._add_unique(file_ids, initial_file_ids or []) | |
| self._add_unique(sediment_ids, initial_sediment_ids or []) | |
| has_initial_ids = bool(file_ids or sediment_ids) | |
| last_hit_key: tuple[tuple[str, ...], tuple[str, ...]] | None = ( | |
| (tuple(file_ids), tuple(sediment_ids)) if has_initial_ids else None | |
| ) | |
| logger.info({ | |
| "event": "image_poll_start", | |
| "conversation_id": conversation_id, | |
| "timeout_secs": timeout_secs, | |
| "initial_wait_secs": initial_wait, | |
| "interval_secs": interval, | |
| "initial_file_ids": file_ids, | |
| "initial_sediment_ids": sediment_ids, | |
| }) | |
| def _remaining() -> float: | |
| return timeout_secs - (time.time() - start) | |
| if has_initial_ids and config.image_settle_enabled: | |
| settle_for = min(config.image_settle_secs, max(0.0, _remaining())) | |
| if settle_for > 0: | |
| time.sleep(settle_for) | |
| elif initial_wait > 0: | |
| jitter = random.uniform(0, min(2.0, initial_wait * 0.2)) | |
| sleep_for = min(initial_wait + jitter, max(0.0, _remaining())) | |
| if sleep_for > 0: | |
| time.sleep(sleep_for) | |
| def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool: | |
| # retry_after=0 means "retry immediately" — must not be coerced via falsy check. | |
| base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16) | |
| backoff = base + random.uniform(0, 0.5) | |
| remaining = _remaining() | |
| if remaining <= 0: | |
| return False | |
| sleep_for = min(backoff, remaining) | |
| log_payload: Dict[str, Any] = { | |
| "event": "image_poll_retry", | |
| "conversation_id": conversation_id, | |
| "attempt": attempt, | |
| "reason": reason, | |
| "sleep_secs": round(sleep_for, 2), | |
| } | |
| if status_code is not None: | |
| log_payload["status_code"] = status_code | |
| if error is not None: | |
| log_payload["error"] = error | |
| logger.warning(log_payload) | |
| time.sleep(sleep_for) | |
| return True | |
| last_task_error = "" | |
| while _remaining() > 0: | |
| attempt += 1 | |
| # 在每次轮询时,检查 /backend-api/tasks/ 是否有错误(仅记录,不中断) | |
| # 内容政策违规检测通过对话文本进行(在 _find_content_policy_error_in_conversation 中) | |
| last_task_error = "" | |
| try: | |
| tasks = self._query_backend_tasks(conversation_id=conversation_id, timeout_secs=5.0) | |
| for task in tasks: | |
| is_error, error_msg, metadata = self.check_task_error(task) | |
| if is_error and error_msg: | |
| last_task_error = error_msg | |
| logger.info({ | |
| "event": "image_poll_task_error_not_blocking", | |
| "conversation_id": conversation_id, | |
| "attempt": attempt, | |
| "error_msg": error_msg, | |
| "metadata": metadata, | |
| }) | |
| except Exception as exc: | |
| # tasks 查询失败不影响正常轮询流程 | |
| logger.debug({ | |
| "event": "image_poll_task_check_failed", | |
| "conversation_id": conversation_id, | |
| "attempt": attempt, | |
| "error": str(exc), | |
| }) | |
| try: | |
| conversation = self._get_conversation(conversation_id) | |
| except UpstreamHTTPError as exc: | |
| if exc.status_code in (429, 500, 502, 503, 504): | |
| if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after): | |
| continue | |
| break | |
| raise | |
| except requests.exceptions.RequestException as exc: | |
| if _retry_sleep("network", None, str(exc), None): | |
| continue | |
| break | |
| for record in self._extract_image_tool_records(conversation): | |
| for file_id in record["file_ids"]: | |
| if file_id not in file_ids: | |
| file_ids.append(file_id) | |
| for sediment_id in record["sediment_ids"]: | |
| if sediment_id not in sediment_ids: | |
| sediment_ids.append(sediment_id) | |
| # 检查对话文本中是否包含内容政策违规错误 | |
| # 当上游拒绝生成图片时,错误消息会出现在对话文档的 assistant 消息中, | |
| # 而非 /backend-api/tasks/ 的 task error 结构中。 | |
| # 如果在没有找到图片文件 ID 的同时检测到内容政策违规,立即中断轮询。 | |
| if not file_ids and not sediment_ids: | |
| policy_msg = self._find_content_policy_error_in_conversation(conversation) | |
| if policy_msg: | |
| logger.warning({ | |
| "event": "image_poll_conversation_text_policy_violation", | |
| "conversation_id": conversation_id, | |
| "attempt": attempt, | |
| "error_msg": policy_msg[:200], | |
| }) | |
| raise ImageContentPolicyError(policy_msg) | |
| logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, | |
| "file_ids": file_ids, "sediment_ids": sediment_ids}) | |
| if file_ids or sediment_ids: | |
| if not config.image_check_before_hit_enabled: | |
| # 先check再hit 机制关闭:直接返回首次发现的 file_ids | |
| logger.info({"event": "image_poll_hit_no_settle", "conversation_id": conversation_id, | |
| "file_ids": file_ids, "sediment_ids": sediment_ids}) | |
| return file_ids, sediment_ids | |
| hit_key = (tuple(file_ids), tuple(sediment_ids)) | |
| if last_hit_key == hit_key: | |
| logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, | |
| "sediment_ids": sediment_ids}) | |
| return file_ids, sediment_ids | |
| last_hit_key = hit_key | |
| if not config.image_settle_enabled: | |
| # 二次确认机制关闭:直接返回首次发现的 file_ids | |
| logger.info({"event": "image_poll_hit_settle_disabled", "conversation_id": conversation_id, | |
| "file_ids": file_ids, "sediment_ids": sediment_ids}) | |
| return file_ids, sediment_ids | |
| logger.info({"event": "image_poll_hit_pending_settle", "conversation_id": conversation_id, | |
| "file_ids": file_ids, "sediment_ids": sediment_ids, | |
| "settle_secs": config.image_settle_secs}) | |
| wait = min(config.image_settle_secs, max(0.0, _remaining())) | |
| if wait > 0: | |
| time.sleep(wait) | |
| continue | |
| return file_ids, sediment_ids | |
| logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, | |
| "elapsed_secs": round(time.time() - start, 1)}) | |
| wait = min(interval, max(0.0, _remaining())) | |
| if wait > 0: | |
| time.sleep(wait) | |
| logger.info({ | |
| "event": "image_poll_timeout", | |
| "conversation_id": conversation_id, | |
| "timeout_secs": timeout_secs, | |
| "attempts_made": attempt, | |
| # attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted. | |
| "initial_wait_exhausted_budget": attempt == 0, | |
| "last_task_error": last_task_error if last_task_error else None, | |
| }) | |
| exc = ImagePollTimeoutError( | |
| f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。" | |
| f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs," | |
| f"也可能是账号被限流或生图队列拥堵导致。" | |
| ) | |
| if last_task_error: | |
| setattr(exc, "task_error", last_task_error) | |
| setattr(exc, "conversation_id", conversation_id or "") | |
| raise exc | |
| def _get_file_download_url(self, file_id: str) -> str: | |
| """获取文件下载地址。""" | |
| path = f"/backend-api/files/{file_id}/download" | |
| response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), | |
| timeout=60) | |
| ensure_ok(response, path) | |
| data = response.json() | |
| return data.get("download_url") or data.get("url") or "" | |
| def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str: | |
| """通过 conversation 附件接口获取下载地址。""" | |
| path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download" | |
| response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), | |
| timeout=60) | |
| ensure_ok(response, path) | |
| data = response.json() | |
| return data.get("download_url") or data.get("url") or "" | |
| def _query_backend_tasks( | |
| self, | |
| conversation_id: str = "", | |
| task_id: str = "", | |
| timeout_secs: float = 30.0, | |
| ) -> list[Dict[str, Any]]: | |
| """查询 /backend-api/tasks/ 接口获取异步任务状态和错误信息。 | |
| 参数: | |
| - `conversation_id`:可选。按 conversation_id 过滤任务。 | |
| - `task_id`:可选。按 task_id 过滤任务。 | |
| - `timeout_secs`:请求超时秒数。 | |
| 返回: | |
| - 任务列表,每个任务包含 image_gen_message 等字段。 | |
| """ | |
| path = "/backend-api/tasks" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Accept": "application/json"}), | |
| timeout=timeout_secs, | |
| ) | |
| ensure_ok(response, path) | |
| data = response.json() | |
| tasks = data.get("tasks", []) | |
| if not isinstance(tasks, list): | |
| return [] | |
| # 按 conversation_id 或 task_id 过滤 | |
| if conversation_id: | |
| tasks = [ | |
| t for t in tasks | |
| if isinstance(t, dict) and ( | |
| t.get("conversation_id") == conversation_id | |
| or t.get("original_conversation_id") == conversation_id | |
| ) | |
| ] | |
| if task_id: | |
| tasks = [t for t in tasks if isinstance(t, dict) and t.get("task_id") == task_id] | |
| return tasks | |
| def check_task_error(self, task: Dict[str, Any]) -> tuple[bool, str, Dict[str, Any]]: | |
| """检查单个任务是否包含结构化错误。 | |
| 通过以下字段判断(不依赖文本匹配): | |
| - image_gen_message.metadata.is_error == True | |
| - image_gen_message.author.role == "assistant" (而非 "tool") | |
| - image_gen_message.content.content_type == "text" (而非 "multimodal_text") | |
| 返回: | |
| - (is_error, error_msg, metadata) | |
| """ | |
| img_msg = task.get("image_gen_message") or {} | |
| if not img_msg: | |
| return False, "", {} | |
| metadata = img_msg.get("metadata") or {} | |
| content = img_msg.get("content") or {} | |
| author = img_msg.get("author") or {} | |
| is_error = metadata.get("is_error", False) | |
| is_text_only = content.get("content_type") == "text" | |
| is_assistant_role = author.get("role") == "assistant" | |
| # 提取错误文本 | |
| error_msg = "" | |
| if is_error and is_text_only: | |
| parts = content.get("parts", []) | |
| error_msg = "".join(p for p in parts if isinstance(p, str)) | |
| return is_error, error_msg, metadata | |
| def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]: | |
| """把图片结果 id 解析成可下载 URL。""" | |
| urls = [] | |
| skip_patterns = {"file_upload"} | |
| for file_id in file_ids: | |
| if file_id in skip_patterns: | |
| logger.debug({ | |
| "event": "image_file_id_skipped", | |
| "source": "file", | |
| "conversation_id": conversation_id, | |
| "id": file_id, | |
| }) | |
| continue | |
| try: | |
| url = self._get_file_download_url(file_id) | |
| except Exception as exc: | |
| logger.debug({ | |
| "event": "image_download_url_failed", | |
| "source": "file", | |
| "conversation_id": conversation_id, | |
| "id": file_id, | |
| "error": repr(exc), | |
| }) | |
| continue | |
| if url: | |
| if url not in urls: | |
| urls.append(url) | |
| else: | |
| logger.debug({ | |
| "event": "image_download_url_empty", | |
| "source": "file", | |
| "conversation_id": conversation_id, | |
| "id": file_id, | |
| }) | |
| if not conversation_id or not sediment_ids: | |
| logger.debug({ | |
| "event": "image_urls_resolved", | |
| "conversation_id": conversation_id, | |
| "file_ids": file_ids, | |
| "sediment_ids": sediment_ids, | |
| "urls": urls, | |
| }) | |
| return urls | |
| for sediment_id in sediment_ids: | |
| try: | |
| url = self._get_attachment_download_url(conversation_id, sediment_id) | |
| except Exception as exc: | |
| logger.debug({ | |
| "event": "image_download_url_failed", | |
| "source": "sediment", | |
| "conversation_id": conversation_id, | |
| "id": sediment_id, | |
| "error": repr(exc), | |
| }) | |
| continue | |
| if url: | |
| if url not in urls: | |
| urls.append(url) | |
| else: | |
| logger.debug({ | |
| "event": "image_download_url_empty", | |
| "source": "sediment", | |
| "conversation_id": conversation_id, | |
| "id": sediment_id, | |
| }) | |
| logger.debug({ | |
| "event": "image_urls_resolved", | |
| "conversation_id": conversation_id, | |
| "file_ids": file_ids, | |
| "sediment_ids": sediment_ids, | |
| "urls": urls, | |
| }) | |
| return urls | |
| def resolve_conversation_image_urls( | |
| self, | |
| conversation_id: str, | |
| file_ids: list[str], | |
| sediment_ids: list[str], | |
| poll: bool = True, | |
| poll_timeout_secs: float | None = None, | |
| ) -> list[str]: | |
| file_ids = [item for item in file_ids if item != "file_upload"] | |
| sediment_ids = list(sediment_ids) | |
| timeout = poll_timeout_secs if poll_timeout_secs is not None else config.image_poll_timeout_secs | |
| # 当 check-before-hit 和 settle 均已关闭,且 SSE 已给出 file_ids 时, | |
| # 跳过轮询直接解析 URL,省去 initial_wait + 轮询耗时。 | |
| if poll and conversation_id and (file_ids or sediment_ids): | |
| if not config.image_check_before_hit_enabled and not config.image_settle_enabled: | |
| logger.info({ | |
| "event": "image_resolve_skip_poll_direct_resolve", | |
| "conversation_id": conversation_id, | |
| "file_ids": file_ids, | |
| "sediment_ids": sediment_ids, | |
| }) | |
| return self._resolve_image_urls(conversation_id, file_ids, sediment_ids) | |
| if poll and conversation_id: | |
| logger.info({ | |
| "event": "image_resolve_poll_needed", | |
| "conversation_id": conversation_id, | |
| "initial_file_ids": file_ids, | |
| "initial_sediment_ids": sediment_ids, | |
| "poll_timeout_secs": timeout, | |
| }) | |
| try: | |
| polled_file_ids, polled_sediment_ids = self._poll_image_results( | |
| conversation_id, | |
| timeout, | |
| file_ids, | |
| sediment_ids, | |
| ) | |
| except ImagePollTimeoutError as exc: | |
| # 如果轮询超时且有 task error(如 moderation 拦截),抛出 ImageContentPolicyError | |
| # 而非 ImagePollTimeoutError,让调用方能区分真正的超时和上游拒绝 | |
| task_error = getattr(exc, "task_error", "") | |
| if not file_ids and not sediment_ids: | |
| if task_error: | |
| raise ImageContentPolicyError(task_error) from exc | |
| raise | |
| logger.warning({ | |
| "event": "image_resolve_poll_partial_timeout", | |
| "conversation_id": conversation_id, | |
| "file_ids": file_ids, | |
| "sediment_ids": sediment_ids, | |
| }) | |
| except Exception as exc: | |
| if not file_ids and not sediment_ids: | |
| raise | |
| logger.warning({ | |
| "event": "image_resolve_poll_partial_error", | |
| "conversation_id": conversation_id, | |
| "file_ids": file_ids, | |
| "sediment_ids": sediment_ids, | |
| "error": repr(exc), | |
| }) | |
| else: | |
| file_ids.extend(item for item in polled_file_ids if item and item not in file_ids) | |
| sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids) | |
| return self._resolve_image_urls(conversation_id, file_ids, sediment_ids) | |
| def download_image_bytes(self, urls: list[str]) -> list[bytes]: | |
| images = [] | |
| for url in urls: | |
| response = self.session.get(url, timeout=120) | |
| ensure_ok(response, "image_download") | |
| if response.content not in images: | |
| images.append(response.content) | |
| return images | |
| def stream_conversation( | |
| self, | |
| messages: Optional[list[Dict[str, Any]]] = None, | |
| model: str = "auto", | |
| prompt: str = "", | |
| images: Optional[list[str]] = None, | |
| system_hints: Optional[list[str]] = None, | |
| ) -> Iterator[str]: | |
| system_hints = system_hints or [] | |
| if "picture_v2" in system_hints: | |
| yield from self._stream_picture_conversation(prompt, model, images or []) | |
| return | |
| normalized = messages or [{"role": "user", "content": prompt}] | |
| self._bootstrap() | |
| requirements = self._get_chat_requirements() | |
| path, timezone = self._chat_target() | |
| payload = self._conversation_payload(normalized, model, timezone) | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._conversation_headers(path, requirements), | |
| json=payload, | |
| timeout=300, | |
| stream=True, | |
| ) | |
| ensure_ok(response, path) | |
| try: | |
| yield from iter_sse_payloads(response) | |
| finally: | |
| response.close() | |
| def _report_progress(self, step: str) -> None: | |
| """Report progress step to the callback if set.""" | |
| if self.progress_callback: | |
| try: | |
| self.progress_callback(step) | |
| except Exception: | |
| pass | |
| def _stream_picture_conversation( | |
| self, | |
| prompt: str, | |
| model: str, | |
| images: list[str], | |
| ) -> Iterator[str]: | |
| if not self.access_token: | |
| raise RuntimeError("access_token is required for image endpoints") | |
| self._report_progress("uploading") | |
| references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)] | |
| self._report_progress("bootstrapping") | |
| self._bootstrap() | |
| self._report_progress("getting_token") | |
| requirements = self._get_chat_requirements() | |
| self._report_progress("preparing_conversation") | |
| conduit_token = self._prepare_image_conversation(prompt, requirements, model) | |
| self._report_progress("starting_generation") | |
| response = self._start_image_generation(prompt, requirements, conduit_token, model, references) | |
| self._report_progress("generating") | |
| try: | |
| yield from iter_sse_payloads(response) | |
| finally: | |
| response.close() | |
| def _bootstrap(self) -> None: | |
| """预热首页,并提取 PoW 相关脚本引用。""" | |
| response = self.session.get( | |
| self.base_url + "/", | |
| headers=self._bootstrap_headers(), | |
| timeout=30, | |
| ) | |
| ensure_ok(response, "bootstrap") | |
| self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text) | |
| if not self.pow_script_sources: | |
| self.pow_script_sources = [DEFAULT_POW_SCRIPT] | |
| def _get_chat_requirements(self) -> ChatRequirements: | |
| """获取当前模式对话所需的 sentinel token。""" | |
| path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements" | |
| context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements" | |
| body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)} | |
| response = self.session.post( | |
| self.base_url + path, | |
| headers=self._headers(path, {"Content-Type": "application/json"}), | |
| json=body, | |
| timeout=30, | |
| ) | |
| ensure_ok(response, context) | |
| requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"]) | |
| if not requirements.token: | |
| message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token" | |
| raise RuntimeError(f"{message}: {requirements.raw_finalize}") | |
| return requirements | |
| def _chat_target(self) -> tuple[str, str]: | |
| if self.access_token: | |
| return "/backend-api/conversation", "Asia/Shanghai" | |
| return "/backend-anon/conversation", "America/Los_Angeles" | |
| def list_models(self) -> Dict[str, Any]: | |
| """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。""" | |
| self._bootstrap() | |
| path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else ( | |
| "/backend-anon/models?iim=false&is_gizmo=false" | |
| ) | |
| route = "/backend-api/models" if self.access_token else "/backend-anon/models" | |
| context = "auth_models" if self.access_token else "anon_models" | |
| response = self.session.get( | |
| self.base_url + path, | |
| headers=self._headers(route), | |
| timeout=30, | |
| ) | |
| ensure_ok(response, context) | |
| data = [] | |
| seen = set() | |
| for item in response.json().get("models", []): | |
| if not isinstance(item, dict): | |
| continue | |
| slug = str(item.get("slug", "")).strip() | |
| if not slug or slug in seen: | |
| continue | |
| seen.add(slug) | |
| data.append({ | |
| "id": slug, | |
| "object": "model", | |
| "created": int(item.get("created") or 0), | |
| "owned_by": str(item.get("owned_by") or "chatgpt"), | |
| "permission": [], | |
| "root": slug, | |
| "parent": None, | |
| }) | |
| data.sort(key=lambda item: item["id"]) | |
| return {"object": "list", "data": data} | |