import base64 import os import re import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from io import BytesIO from pathlib import Path from typing import Any, Dict, Iterator, Optional 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 ensure_ok, iter_sse_payloads, new_uuid 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 @dataclass 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-be885abbfcfe7b1f511e88b3003d9ee44757fbad" DEFAULT_CLIENT_BUILD_NUMBER = "5955942" DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js" CODEX_IMAGE_MODEL = "codex-gpt-image-2" 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.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.session = requests.Session(**proxy_settings.build_session_kwargs( 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 = account_service.get_account(self.access_token) if self.access_token else {} account = account if isinstance(account, dict) else {} 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 @staticmethod 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 _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: if response.status_code == 401: raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}") raise RuntimeError(f"{path} failed: HTTP {response.status_code}") 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: if response.status_code == 401: raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}") raise RuntimeError(f"{path} failed: HTTP {response.status_code}") return response.json() def _get_default_account(self) -> Dict[str, Any]: route = "/backend-api/accounts/check/v4-2023-04-27" response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route), timeout=20) if response.status_code != 200: if response.status_code == 401: raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}") raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}") payload = response.json() logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload}) return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {} def get_user_info(self) -> Dict[str, Any]: """获取当前 token 的账号信息。""" if not self.access_token: raise RuntimeError("access_token is required") logger.debug({"event": "backend_user_info_start"}) with ThreadPoolExecutor(max_workers=3) as executor: 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() 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。""" model = str(model or "").strip() if not model: return "auto" if model == "gpt-image-2": return "gpt-5-3" if model == CODEX_IMAGE_MODEL: return 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 _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() time.sleep(0.5) 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 _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]: """从 conversation 明细里提取图片工具输出记录。""" mapping = data.get("mapping") or {} file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)") sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)") 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 {} if author.get("role") != "tool": continue if metadata.get("async_task_type") != "image_gen": continue if content.get("content_type") != "multimodal_text": continue file_ids, sediment_ids = [], [] for part in content.get("parts") or []: text = (part.get("asset_pointer") or "") if isinstance(part, dict) else ( part if isinstance(part, str) else "") for hit in file_pat.findall(text): if hit not in file_ids: file_ids.append(hit) for hit in sed_pat.findall(text): if hit not in sediment_ids: sediment_ids.append(hit) 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 _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]: """轮询 conversation,直到拿到图片文件 id 或超时。""" start = time.time() attempt = 0 logger.info({"event": "image_poll_start", "conversation_id": conversation_id, "timeout_secs": timeout_secs}) while time.time() - start < timeout_secs: attempt += 1 conversation = self._get_conversation(conversation_id) file_ids, sediment_ids = [], [] 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) logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, "file_ids": file_ids, "sediment_ids": sediment_ids}) if file_ids: logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids}) return file_ids, sediment_ids if sediment_ids: logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [], "sediment_ids": sediment_ids}) return [], sediment_ids logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, "elapsed_secs": round(time.time() - start, 1)}) time.sleep(4) logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs}) return [], [] 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 _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: urls.append(url) else: logger.debug({ "event": "image_download_url_empty", "source": "file", "conversation_id": conversation_id, "id": file_id, }) if urls or not conversation_id: 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: 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, ) -> list[str]: file_ids = [item for item in file_ids if item != "file_upload"] sediment_ids = list(sediment_ids) if poll and conversation_id and not file_ids and not sediment_ids: logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id}) polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id, config.image_poll_timeout_secs) 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") 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 _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") references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)] self._bootstrap() requirements = self._get_chat_requirements() conduit_token = self._prepare_image_conversation(prompt, requirements, model) response = self._start_image_generation(prompt, requirements, conduit_token, model, references) 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}