dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,2763 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import mimetypes
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any, Dict, Iterator, Optional
|
|
18
|
+
from urllib.parse import unquote, urlparse
|
|
19
|
+
|
|
20
|
+
from curl_cffi import requests
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
from services.account_service import account_service
|
|
24
|
+
from services.config import config
|
|
25
|
+
from services.proxy_service import proxy_settings
|
|
26
|
+
from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model
|
|
27
|
+
from utils.log import logger
|
|
28
|
+
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
|
29
|
+
from utils.turnstile import solve_turnstile_token
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidAccessTokenError(RuntimeError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ImageTaskError(RuntimeError):
|
|
37
|
+
"""图片生成异常基类,携带上游会话 ID 供调用方清理对话。"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str = "", conversation_id: str = "") -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.conversation_id = conversation_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ImagePollTimeoutError(ImageTaskError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ImageContentPolicyError(ImageTaskError):
|
|
49
|
+
"""Raised when image generation is blocked by content policy moderation."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ImageStreamHardTimeoutError(RuntimeError):
|
|
54
|
+
"""图片 SSE 流读取超过硬上限时抛出,用于快速中断被挂起的长连接。"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ChatRequirements:
|
|
60
|
+
"""保存一次对话请求所需的 sentinel token。"""
|
|
61
|
+
token: str
|
|
62
|
+
proof_token: str = ""
|
|
63
|
+
turnstile_token: str = ""
|
|
64
|
+
so_token: str = ""
|
|
65
|
+
raw_finalize: Optional[Dict[str, Any]] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
DEFAULT_CLIENT_VERSION = "prod-a194cd50d4416d3c0b47c740f206b12ce60f5887"
|
|
69
|
+
DEFAULT_CLIENT_BUILD_NUMBER = "6708908"
|
|
70
|
+
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
|
71
|
+
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
|
72
|
+
CODEX_RESPONSES_MODEL = "gpt-5.5"
|
|
73
|
+
SEARCH_MODEL = "gpt-5-5"
|
|
74
|
+
SEARCH_TIMEOUT_SECS = 300.0
|
|
75
|
+
SEARCH_POLL_INTERVAL_SECS = 3.0
|
|
76
|
+
SEARCH_DONE_STATUS = {"finished_successfully", "finished_partial_completion"}
|
|
77
|
+
SEARCH_CONVERSATION_ID_RE = re.compile(r'"conversation_id"\s*:\s*"([^"]+)"')
|
|
78
|
+
SEARCH_URL_RE = re.compile(r"https?://[^\s\"'<>))\]}]+")
|
|
79
|
+
EDITABLE_FILE_MODEL = "gpt-5-5-thinking"
|
|
80
|
+
EDITABLE_FILE_THINKING_EFFORT = "extended"
|
|
81
|
+
EDITABLE_FILE_TIMEOUT_SECS = 1200.0
|
|
82
|
+
EDITABLE_FILE_POLL_INTERVAL_SECS = 5.0
|
|
83
|
+
EDITABLE_FILE_CLIENT_VERSION = "prod-bede35f9dcd856d080e012478f0c1031faa2588e"
|
|
84
|
+
EDITABLE_FILE_CLIENT_BUILD_NUMBER = "6631702"
|
|
85
|
+
EDITABLE_FILE_PSD_OUTPUT_DIR = "data/files/psd"
|
|
86
|
+
EDITABLE_FILE_PPT_OUTPUT_DIR = "data/files/ppt"
|
|
87
|
+
EDITABLE_FILE_PPT_PROMPT = """我需要你根据用户的需求,来制作一个可以编辑的PPT,你可以使用Agent来做,你不要再继续询问用户问题,内容风格、版式、配色、内容结构和页面信息你可以自行补充并直接执行。整体的流程如下:
|
|
88
|
+
1. 用生图的方式,帮我生成一个精美的产品介绍ppt,5-6个页面
|
|
89
|
+
2. 帮我把以上涉及到的所有图像和形状素材拆分成单独png,每个素材单独一张图片,不要有遗漏,让我可以直接在ppt里拼接素材还原,不要文字
|
|
90
|
+
3. 利用以上所有图片和形状素材,帮我还原你第一次生成的展示ppt,我需要是可编辑的ppt格式,主要部分需要你单独还原插入,文字需要可以编辑
|
|
91
|
+
最后只需要给我生成一个PPT文件,以及生成中遇到的各种素材压缩包zip文件就行。"""
|
|
92
|
+
EDITABLE_FILE_PSD_PROMPT = "帮我生成这个图像,把这张海报分成若干图像,包括背景图,每个元素不要改位置,这样子我可以直接在 平时里无需拖动,底色为白色,不要伪透明底。再帮我将以上拆分的图像拼合成一个psd文件,去除白色底,不要改变每个图层的相应位置,保留每个元素所在图层的相应位置,保留每个元素的图层,最后只需要给我输出psd文件,以及每个图层的zip文件"
|
|
93
|
+
EDITABLE_ASSET_POINTER_RE = re.compile(r"(?:file-service|sediment)://([A-Za-z0-9_-]+)")
|
|
94
|
+
EDITABLE_ZIP_MIME_TYPES = {"application/zip", "application/x-zip-compressed"}
|
|
95
|
+
EDITABLE_PSD_MIME_TYPES = {"image/vnd.adobe.photoshop", "application/vnd.adobe.photoshop"}
|
|
96
|
+
EDITABLE_PPT_MIME_TYPES = {
|
|
97
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
98
|
+
"application/vnd.ms-powerpoint",
|
|
99
|
+
}
|
|
100
|
+
EDITABLE_PSD_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:psd|zip))", re.IGNORECASE)
|
|
101
|
+
EDITABLE_PPT_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:pptx?|zip))", re.IGNORECASE)
|
|
102
|
+
FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
|
|
103
|
+
FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
|
|
104
|
+
# 真正的图片文件 ID 格式:file_00000000 + 24位十六进制字符(共32字符)
|
|
105
|
+
REAL_IMAGE_FILE_ID_RE = re.compile(r"\bfile_00000000[a-f0-9]{24}\b")
|
|
106
|
+
SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
|
|
107
|
+
IMAGE_POLL_SETTLE_SECS = 2.0
|
|
108
|
+
CODEX_RESPONSES_INSTRUCTIONS = (
|
|
109
|
+
"Use the image_generation tool to create exactly one image for the user's request. "
|
|
110
|
+
"Return the generated image result."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 内容政策违规错误关键词(上游拒绝生成图片的各种表述)
|
|
114
|
+
_CONTENT_POLICY_KEYWORDS = (
|
|
115
|
+
# 明确的内容政策违规
|
|
116
|
+
"内容政策", "防护限制", "违反", "moderation", "policy", "blocked",
|
|
117
|
+
# 拒绝生成类
|
|
118
|
+
"不能生成", "无法生成", "不能帮助", "无法帮助",
|
|
119
|
+
# 敏感内容类
|
|
120
|
+
"裸体", "裸露", "色情", "性内容", "未成年",
|
|
121
|
+
# 通用拒绝
|
|
122
|
+
"抱歉,我不能",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_content_policy_error(error_msg: str) -> bool:
|
|
127
|
+
"""检查错误消息是否为内容政策违规。"""
|
|
128
|
+
if not error_msg:
|
|
129
|
+
return False
|
|
130
|
+
msg_lower = error_msg.lower()
|
|
131
|
+
return any(keyword in msg_lower for keyword in _CONTENT_POLICY_KEYWORDS)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class EditableFileArtifact:
|
|
136
|
+
attachment_id: str = ""
|
|
137
|
+
file_id: str = ""
|
|
138
|
+
name: str = ""
|
|
139
|
+
mime_type: str = ""
|
|
140
|
+
create_time: float = 0.0
|
|
141
|
+
author_role: str = ""
|
|
142
|
+
sandbox_path: str = ""
|
|
143
|
+
message_id: str = ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class EditableFileExportResult:
|
|
148
|
+
conversation_id: str
|
|
149
|
+
primary_path: Path
|
|
150
|
+
zip_path: Path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class OpenAIBackendAPI:
|
|
154
|
+
"""ChatGPT Web 后端封装。
|
|
155
|
+
|
|
156
|
+
说明:
|
|
157
|
+
- 传入 `access_token` 时,聊天和模型列表都会走已登录链路
|
|
158
|
+
例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
|
|
159
|
+
- 不传 `access_token` 时,会走未登录链路
|
|
160
|
+
例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
|
|
161
|
+
- `stream_conversation()` 是底层统一流式入口
|
|
162
|
+
- 协议兼容转换放在 `services.protocol`
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, access_token: str = "") -> None:
|
|
166
|
+
"""初始化后端客户端。
|
|
167
|
+
|
|
168
|
+
参数:
|
|
169
|
+
- `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
|
|
170
|
+
"""
|
|
171
|
+
self.base_url = "https://chatgpt.com"
|
|
172
|
+
self.client_version = DEFAULT_CLIENT_VERSION
|
|
173
|
+
self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
|
|
174
|
+
self.access_token = access_token
|
|
175
|
+
self.account = account_service.get_account(self.access_token) if self.access_token else {}
|
|
176
|
+
self.account = self.account if isinstance(self.account, dict) else {}
|
|
177
|
+
self.fp = self._build_fp()
|
|
178
|
+
self.user_agent = self.fp["user-agent"]
|
|
179
|
+
self.device_id = self.fp["oai-device-id"]
|
|
180
|
+
self.session_id = self.fp["oai-session-id"]
|
|
181
|
+
self.pow_script_sources: list[str] = []
|
|
182
|
+
self.pow_data_build = ""
|
|
183
|
+
self.progress_callback: Callable[[str], None] | None = None
|
|
184
|
+
self.session = requests.Session(**proxy_settings.build_session_kwargs(
|
|
185
|
+
account=self.account,
|
|
186
|
+
impersonate=self.fp["impersonate"],
|
|
187
|
+
verify=True,
|
|
188
|
+
))
|
|
189
|
+
self.session.headers.update({
|
|
190
|
+
"User-Agent": self.user_agent,
|
|
191
|
+
"Origin": self.base_url,
|
|
192
|
+
"Referer": self.base_url + "/",
|
|
193
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
|
|
194
|
+
"Cache-Control": "no-cache",
|
|
195
|
+
"Pragma": "no-cache",
|
|
196
|
+
"Priority": "u=1, i",
|
|
197
|
+
"Sec-Ch-Ua": self.fp["sec-ch-ua"],
|
|
198
|
+
"Sec-Ch-Ua-Arch": '"x86"',
|
|
199
|
+
"Sec-Ch-Ua-Bitness": '"64"',
|
|
200
|
+
"Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
|
|
201
|
+
"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"',
|
|
202
|
+
"Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
|
|
203
|
+
"Sec-Ch-Ua-Model": '""',
|
|
204
|
+
"Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
|
|
205
|
+
"Sec-Ch-Ua-Platform-Version": '"19.0.0"',
|
|
206
|
+
"Sec-Fetch-Dest": "empty",
|
|
207
|
+
"Sec-Fetch-Mode": "cors",
|
|
208
|
+
"Sec-Fetch-Site": "same-origin",
|
|
209
|
+
"OAI-Device-Id": self.device_id,
|
|
210
|
+
"OAI-Session-Id": self.session_id,
|
|
211
|
+
"OAI-Language": "zh-CN",
|
|
212
|
+
"OAI-Client-Version": self.client_version,
|
|
213
|
+
"OAI-Client-Build-Number": self.client_build_number,
|
|
214
|
+
})
|
|
215
|
+
if self.access_token:
|
|
216
|
+
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
|
217
|
+
|
|
218
|
+
def close(self) -> None:
|
|
219
|
+
if getattr(self, "_closed", False):
|
|
220
|
+
return
|
|
221
|
+
self._closed = True
|
|
222
|
+
session = getattr(self, "session", None)
|
|
223
|
+
if session:
|
|
224
|
+
try:
|
|
225
|
+
session.close()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def __del__(self):
|
|
230
|
+
self.close()
|
|
231
|
+
|
|
232
|
+
def __enter__(self):
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *args):
|
|
236
|
+
self.close()
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def _build_fp(self) -> Dict[str, str]:
|
|
240
|
+
account = self.account
|
|
241
|
+
raw_fp = account.get("fp")
|
|
242
|
+
fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
|
|
243
|
+
for key in (
|
|
244
|
+
"user-agent",
|
|
245
|
+
"impersonate",
|
|
246
|
+
"oai-device-id",
|
|
247
|
+
"oai-session-id",
|
|
248
|
+
"sec-ch-ua",
|
|
249
|
+
"sec-ch-ua-mobile",
|
|
250
|
+
"sec-ch-ua-platform",
|
|
251
|
+
):
|
|
252
|
+
value = str(account.get(key) or "").strip()
|
|
253
|
+
if value:
|
|
254
|
+
fp[key] = value
|
|
255
|
+
fp.setdefault(
|
|
256
|
+
"user-agent",
|
|
257
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
258
|
+
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
|
|
259
|
+
)
|
|
260
|
+
fp.setdefault("impersonate", "chrome110")
|
|
261
|
+
fp.setdefault("oai-device-id", new_uuid())
|
|
262
|
+
fp.setdefault("oai-session-id", new_uuid())
|
|
263
|
+
fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
|
|
264
|
+
fp.setdefault("sec-ch-ua-mobile", "?0")
|
|
265
|
+
fp.setdefault("sec-ch-ua-platform", '"Windows"')
|
|
266
|
+
return fp
|
|
267
|
+
|
|
268
|
+
def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
269
|
+
"""构造请求头,并补上 web 端要求的 target path/route。"""
|
|
270
|
+
headers = dict(self.session.headers)
|
|
271
|
+
headers["X-OpenAI-Target-Path"] = path
|
|
272
|
+
headers["X-OpenAI-Target-Route"] = path
|
|
273
|
+
if extra:
|
|
274
|
+
headers.update(extra)
|
|
275
|
+
return headers
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None]:
|
|
279
|
+
for item in limits_progress:
|
|
280
|
+
if isinstance(item, dict) and item.get("feature_name") == "image_gen":
|
|
281
|
+
return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None
|
|
282
|
+
return 0, None
|
|
283
|
+
|
|
284
|
+
def _raise_on_error(self, response: Any, path: str) -> None:
|
|
285
|
+
if response.status_code == 401:
|
|
286
|
+
raise InvalidAccessTokenError(f"token invalidated ({path})")
|
|
287
|
+
raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
|
|
288
|
+
|
|
289
|
+
def _get_me(self) -> Dict[str, Any]:
|
|
290
|
+
path = "/backend-api/me"
|
|
291
|
+
response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
|
|
292
|
+
if response.status_code != 200:
|
|
293
|
+
self._raise_on_error(response, path)
|
|
294
|
+
return response.json()
|
|
295
|
+
|
|
296
|
+
def _get_conversation_init(self) -> Dict[str, Any]:
|
|
297
|
+
path = "/backend-api/conversation/init"
|
|
298
|
+
response = self.session.post(
|
|
299
|
+
self.base_url + path,
|
|
300
|
+
headers=self._headers(path, {"Content-Type": "application/json"}),
|
|
301
|
+
json={
|
|
302
|
+
"gizmo_id": None,
|
|
303
|
+
"requested_default_model": None,
|
|
304
|
+
"conversation_id": None,
|
|
305
|
+
"timezone_offset_min": -480,
|
|
306
|
+
},
|
|
307
|
+
timeout=20,
|
|
308
|
+
)
|
|
309
|
+
if response.status_code != 200:
|
|
310
|
+
self._raise_on_error(response, path)
|
|
311
|
+
return response.json()
|
|
312
|
+
|
|
313
|
+
def _get_default_account(self) -> Dict[str, Any]:
|
|
314
|
+
path = "/backend-api/accounts/check/v4-2023-04-27"
|
|
315
|
+
response = self.session.get(self.base_url + path + "?timezone_offset_min=-480", headers=self._headers(path),
|
|
316
|
+
timeout=20)
|
|
317
|
+
if response.status_code != 200:
|
|
318
|
+
self._raise_on_error(response, path)
|
|
319
|
+
payload = response.json()
|
|
320
|
+
default_account = ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
|
|
321
|
+
logger.debug({
|
|
322
|
+
"event": "backend_user_info_account_payload",
|
|
323
|
+
"plan_type": default_account.get("plan_type"),
|
|
324
|
+
"account_user_role": default_account.get("account_user_role"),
|
|
325
|
+
"account_id": default_account.get("account_id"),
|
|
326
|
+
"is_deactivated": default_account.get("is_deactivated"),
|
|
327
|
+
"has_active_subscription": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("has_active_subscription"),
|
|
328
|
+
"subscription_plan": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("subscription_plan"),
|
|
329
|
+
})
|
|
330
|
+
return default_account
|
|
331
|
+
|
|
332
|
+
def get_user_info(self) -> Dict[str, Any]:
|
|
333
|
+
"""获取当前 token 的账号信息。"""
|
|
334
|
+
if not self.access_token:
|
|
335
|
+
raise RuntimeError("access_token is required")
|
|
336
|
+
executor = ThreadPoolExecutor(max_workers=3)
|
|
337
|
+
try:
|
|
338
|
+
me_future = executor.submit(self._get_me)
|
|
339
|
+
init_future = executor.submit(self._get_conversation_init)
|
|
340
|
+
account_future = executor.submit(self._get_default_account)
|
|
341
|
+
me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
|
|
342
|
+
except (KeyboardInterrupt, SystemExit):
|
|
343
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
344
|
+
raise
|
|
345
|
+
except BaseException:
|
|
346
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
347
|
+
raise
|
|
348
|
+
else:
|
|
349
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
350
|
+
|
|
351
|
+
plan_type = str(default_account.get("plan_type") or "free")
|
|
352
|
+
|
|
353
|
+
limits_progress = init_payload.get("limits_progress")
|
|
354
|
+
limits_progress = limits_progress if isinstance(limits_progress, list) else []
|
|
355
|
+
quota, restore_at = self._extract_quota_and_restore_at(limits_progress)
|
|
356
|
+
result = {
|
|
357
|
+
"email": me_payload.get("email"),
|
|
358
|
+
"user_id": me_payload.get("id"),
|
|
359
|
+
"type": plan_type,
|
|
360
|
+
"quota": quota,
|
|
361
|
+
"limits_progress": limits_progress,
|
|
362
|
+
"default_model_slug": init_payload.get("default_model_slug"),
|
|
363
|
+
"restore_at": restore_at,
|
|
364
|
+
"status": "限流" if quota == 0 else "正常",
|
|
365
|
+
}
|
|
366
|
+
logger.debug({
|
|
367
|
+
"event": "backend_user_info_result",
|
|
368
|
+
"email": result.get("email"),
|
|
369
|
+
"user_id": result.get("user_id"),
|
|
370
|
+
"type": result.get("type"),
|
|
371
|
+
"quota": result.get("quota"),
|
|
372
|
+
"default_model_slug": result.get("default_model_slug"),
|
|
373
|
+
"restore_at": result.get("restore_at"),
|
|
374
|
+
"status": result.get("status"),
|
|
375
|
+
})
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
def _bootstrap_headers(self) -> Dict[str, str]:
|
|
379
|
+
"""构造首页预热请求头。"""
|
|
380
|
+
return {
|
|
381
|
+
"User-Agent": self.user_agent,
|
|
382
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
383
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
384
|
+
"Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
|
|
385
|
+
"Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
|
|
386
|
+
"Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
|
|
387
|
+
"Sec-Fetch-Dest": "document",
|
|
388
|
+
"Sec-Fetch-Mode": "navigate",
|
|
389
|
+
"Sec-Fetch-Site": "none",
|
|
390
|
+
"Sec-Fetch-User": "?1",
|
|
391
|
+
"Upgrade-Insecure-Requests": "1",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
|
|
395
|
+
"""把 sentinel 响应整理成后续对话需要的 token 集合。"""
|
|
396
|
+
if (data.get("arkose") or {}).get("required"):
|
|
397
|
+
raise RuntimeError("chat requirements requires arkose token, which is not implemented")
|
|
398
|
+
|
|
399
|
+
proof_token = ""
|
|
400
|
+
proof_info = data.get("proofofwork") or {}
|
|
401
|
+
if proof_info.get("required"):
|
|
402
|
+
proof_token = build_proof_token(
|
|
403
|
+
proof_info.get("seed", ""),
|
|
404
|
+
proof_info.get("difficulty", ""),
|
|
405
|
+
self.user_agent,
|
|
406
|
+
script_sources=self.pow_script_sources,
|
|
407
|
+
data_build=self.pow_data_build,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
turnstile_token = ""
|
|
411
|
+
turnstile_info = data.get("turnstile") or {}
|
|
412
|
+
if turnstile_info.get("required") and turnstile_info.get("dx"):
|
|
413
|
+
turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
|
|
414
|
+
|
|
415
|
+
return ChatRequirements(
|
|
416
|
+
token=data.get("token", ""),
|
|
417
|
+
proof_token=proof_token,
|
|
418
|
+
turnstile_token=turnstile_token,
|
|
419
|
+
so_token=data.get("so_token", ""),
|
|
420
|
+
raw_finalize=data,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
|
|
424
|
+
"""根据当前 requirements 构造对话 SSE 请求头。"""
|
|
425
|
+
headers = {
|
|
426
|
+
"Accept": "text/event-stream",
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
|
429
|
+
}
|
|
430
|
+
if requirements.proof_token:
|
|
431
|
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
|
432
|
+
if requirements.turnstile_token:
|
|
433
|
+
headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
|
|
434
|
+
if requirements.so_token:
|
|
435
|
+
headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
|
|
436
|
+
return self._headers(path, headers)
|
|
437
|
+
|
|
438
|
+
def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
|
439
|
+
"""把标准 chat messages 转成 web conversation 所需的 messages。"""
|
|
440
|
+
conversation_messages = []
|
|
441
|
+
for item in messages:
|
|
442
|
+
role = item.get("role", "user")
|
|
443
|
+
content = item.get("content", "")
|
|
444
|
+
if isinstance(content, str):
|
|
445
|
+
conversation_messages.append({
|
|
446
|
+
"id": new_uuid(),
|
|
447
|
+
"author": {"role": role},
|
|
448
|
+
"content": {"content_type": "text", "parts": [content]},
|
|
449
|
+
})
|
|
450
|
+
continue
|
|
451
|
+
if not isinstance(content, list):
|
|
452
|
+
raise RuntimeError("only string or list message content is supported")
|
|
453
|
+
text_parts: list[str] = []
|
|
454
|
+
image_inputs: list[tuple[bytes, str]] = []
|
|
455
|
+
for part in content:
|
|
456
|
+
if not isinstance(part, dict):
|
|
457
|
+
continue
|
|
458
|
+
part_type = str(part.get("type") or "")
|
|
459
|
+
if part_type == "text":
|
|
460
|
+
text_parts.append(str(part.get("text") or ""))
|
|
461
|
+
elif part_type == "image":
|
|
462
|
+
data = part.get("data")
|
|
463
|
+
mime = str(part.get("mime") or "image/png")
|
|
464
|
+
if isinstance(data, (bytes, bytearray)):
|
|
465
|
+
image_inputs.append((bytes(data), mime))
|
|
466
|
+
if not image_inputs:
|
|
467
|
+
conversation_messages.append({
|
|
468
|
+
"id": new_uuid(),
|
|
469
|
+
"author": {"role": role},
|
|
470
|
+
"content": {"content_type": "text", "parts": ["".join(text_parts)]},
|
|
471
|
+
})
|
|
472
|
+
continue
|
|
473
|
+
if not self.access_token:
|
|
474
|
+
raise RuntimeError("authenticated upstream account required for image input")
|
|
475
|
+
uploaded: list[Dict[str, Any]] = []
|
|
476
|
+
for idx, (data, mime) in enumerate(image_inputs, start=1):
|
|
477
|
+
ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
|
|
478
|
+
extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
|
|
479
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
480
|
+
uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
|
|
481
|
+
parts: list[Any] = []
|
|
482
|
+
for ref in uploaded:
|
|
483
|
+
parts.append({
|
|
484
|
+
"content_type": "image_asset_pointer",
|
|
485
|
+
"asset_pointer": f"file-service://{ref['file_id']}",
|
|
486
|
+
"width": ref["width"],
|
|
487
|
+
"height": ref["height"],
|
|
488
|
+
"size_bytes": ref["file_size"],
|
|
489
|
+
})
|
|
490
|
+
text = "".join(text_parts)
|
|
491
|
+
if text:
|
|
492
|
+
parts.append(text)
|
|
493
|
+
conversation_messages.append({
|
|
494
|
+
"id": new_uuid(),
|
|
495
|
+
"author": {"role": role},
|
|
496
|
+
"content": {"content_type": "multimodal_text", "parts": parts},
|
|
497
|
+
"metadata": {
|
|
498
|
+
"attachments": [{
|
|
499
|
+
"id": ref["file_id"],
|
|
500
|
+
"mimeType": ref["mime_type"],
|
|
501
|
+
"name": ref["file_name"],
|
|
502
|
+
"size": ref["file_size"],
|
|
503
|
+
"width": ref["width"],
|
|
504
|
+
"height": ref["height"],
|
|
505
|
+
} for ref in uploaded],
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
return conversation_messages
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def _normalize_thinking_effort(value: str) -> str:
|
|
512
|
+
normalized = str(value or "").strip().lower()
|
|
513
|
+
if normalized in {"", "none", "auto"}:
|
|
514
|
+
return ""
|
|
515
|
+
if normalized in {"low", "medium", "high", "standard", "max"}:
|
|
516
|
+
return normalized
|
|
517
|
+
if normalized in {"xhigh", "extended"}:
|
|
518
|
+
return "extended"
|
|
519
|
+
return ""
|
|
520
|
+
|
|
521
|
+
def _conversation_payload(
|
|
522
|
+
self,
|
|
523
|
+
messages: list[Dict[str, Any]],
|
|
524
|
+
model: str,
|
|
525
|
+
timezone: str,
|
|
526
|
+
thinking_effort: str = "",
|
|
527
|
+
) -> Dict[str, Any]:
|
|
528
|
+
"""把标准 messages 构造成 web 对话请求体。"""
|
|
529
|
+
payload = {
|
|
530
|
+
"action": "next",
|
|
531
|
+
"messages": self._api_messages_to_conversation_messages(messages),
|
|
532
|
+
"model": model,
|
|
533
|
+
"parent_message_id": new_uuid(),
|
|
534
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
535
|
+
"conversation_origin": None,
|
|
536
|
+
"force_paragen": False,
|
|
537
|
+
"force_paragen_model_slug": "",
|
|
538
|
+
"force_rate_limit": False,
|
|
539
|
+
"force_use_sse": True,
|
|
540
|
+
"history_and_training_disabled": True,
|
|
541
|
+
"reset_rate_limits": False,
|
|
542
|
+
"suggestions": [],
|
|
543
|
+
"supported_encodings": [],
|
|
544
|
+
"system_hints": [],
|
|
545
|
+
"timezone": timezone,
|
|
546
|
+
"timezone_offset_min": -480,
|
|
547
|
+
"variant_purpose": "comparison_implicit",
|
|
548
|
+
"websocket_request_id": new_uuid(),
|
|
549
|
+
"client_contextual_info": {
|
|
550
|
+
"is_dark_mode": False,
|
|
551
|
+
"time_since_loaded": 120,
|
|
552
|
+
"page_height": 900,
|
|
553
|
+
"page_width": 1400,
|
|
554
|
+
"pixel_ratio": 2,
|
|
555
|
+
"screen_height": 1440,
|
|
556
|
+
"screen_width": 2560,
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
normalized_effort = self._normalize_thinking_effort(thinking_effort or config.default_thinking_effort)
|
|
560
|
+
if normalized_effort:
|
|
561
|
+
payload["thinking_effort"] = normalized_effort
|
|
562
|
+
return payload
|
|
563
|
+
|
|
564
|
+
def _image_model_settings(self, model: str) -> tuple[str, str]:
|
|
565
|
+
"""把标准图片模型名映射为上游模型及思考强度。"""
|
|
566
|
+
_, base_model = split_image_model(model)
|
|
567
|
+
if not base_model:
|
|
568
|
+
return "auto", ""
|
|
569
|
+
if base_model == "gpt-image-2":
|
|
570
|
+
upstream_model = config.default_upstream_model_name
|
|
571
|
+
elif base_model == CODEX_IMAGE_MODEL:
|
|
572
|
+
upstream_model = base_model
|
|
573
|
+
else:
|
|
574
|
+
return "auto", ""
|
|
575
|
+
model_name, separator, suffix = upstream_model.rpartition("-")
|
|
576
|
+
if separator and suffix.lower() in {"standard", "extended", "max"}:
|
|
577
|
+
return model_name, suffix.lower()
|
|
578
|
+
return upstream_model, self._normalize_thinking_effort(config.default_thinking_effort)
|
|
579
|
+
|
|
580
|
+
def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
|
|
581
|
+
Dict[str, str]:
|
|
582
|
+
"""构造图片链路请求头。"""
|
|
583
|
+
headers = {
|
|
584
|
+
"Content-Type": "application/json",
|
|
585
|
+
"Accept": accept,
|
|
586
|
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
|
587
|
+
}
|
|
588
|
+
if requirements.proof_token:
|
|
589
|
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
|
590
|
+
if conduit_token:
|
|
591
|
+
headers["X-Conduit-Token"] = conduit_token
|
|
592
|
+
if accept == "text/event-stream":
|
|
593
|
+
headers["X-Oai-Turn-Trace-Id"] = new_uuid()
|
|
594
|
+
return self._headers(path, headers)
|
|
595
|
+
|
|
596
|
+
def _codex_responses_headers(self) -> Dict[str, str]:
|
|
597
|
+
return {
|
|
598
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
599
|
+
"Content-Type": "application/json",
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
def _ensure_codex_source_account(self) -> None:
|
|
603
|
+
account = account_service.get_account(self.access_token)
|
|
604
|
+
source_type = str((account or {}).get("source_type") or "web").strip().lower()
|
|
605
|
+
if source_type != "codex":
|
|
606
|
+
raise RuntimeError("codex responses endpoint requires a codex source account")
|
|
607
|
+
|
|
608
|
+
@staticmethod
|
|
609
|
+
def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]:
|
|
610
|
+
content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
|
611
|
+
for image in images:
|
|
612
|
+
payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}"
|
|
613
|
+
content.append({"type": "input_image", "image_url": payload})
|
|
614
|
+
return [{"role": "user", "content": content}]
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def _codex_body_preview(body: Any, limit: int = 4000) -> str:
|
|
618
|
+
if isinstance(body, (dict, list)):
|
|
619
|
+
try:
|
|
620
|
+
text = json.dumps(body, ensure_ascii=False)
|
|
621
|
+
except Exception:
|
|
622
|
+
text = repr(body)
|
|
623
|
+
else:
|
|
624
|
+
text = str(body or "")
|
|
625
|
+
return text if len(text) <= limit else text[:limit] + "...[truncated]"
|
|
626
|
+
|
|
627
|
+
@staticmethod
|
|
628
|
+
def _codex_event_image_result_lengths(value: Any) -> list[int]:
|
|
629
|
+
if isinstance(value, dict):
|
|
630
|
+
lengths: list[int] = []
|
|
631
|
+
if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
|
|
632
|
+
lengths.append(len(value["result"]))
|
|
633
|
+
for item in value.values():
|
|
634
|
+
lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
|
|
635
|
+
return lengths
|
|
636
|
+
if isinstance(value, list):
|
|
637
|
+
lengths: list[int] = []
|
|
638
|
+
for item in value:
|
|
639
|
+
lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
|
|
640
|
+
return lengths
|
|
641
|
+
return []
|
|
642
|
+
|
|
643
|
+
@staticmethod
|
|
644
|
+
def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
645
|
+
summary: Dict[str, Any] = {
|
|
646
|
+
"type": str(event.get("type") or ""),
|
|
647
|
+
"keys": list(event.keys())[:30],
|
|
648
|
+
}
|
|
649
|
+
for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"):
|
|
650
|
+
value = event.get(key)
|
|
651
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
652
|
+
summary[key] = value
|
|
653
|
+
for key in ("response", "item", "output"):
|
|
654
|
+
value = event.get(key)
|
|
655
|
+
if isinstance(value, dict):
|
|
656
|
+
summary[f"{key}_type"] = value.get("type")
|
|
657
|
+
summary[f"{key}_status"] = value.get("status")
|
|
658
|
+
summary[f"{key}_keys"] = list(value.keys())[:30]
|
|
659
|
+
elif isinstance(value, list):
|
|
660
|
+
summary[f"{key}_len"] = len(value)
|
|
661
|
+
summary[f"{key}_types"] = [
|
|
662
|
+
item.get("type") for item in value[:10] if isinstance(item, dict)
|
|
663
|
+
]
|
|
664
|
+
error = event.get("error")
|
|
665
|
+
if isinstance(error, dict):
|
|
666
|
+
summary["error"] = {
|
|
667
|
+
key: error.get(key)
|
|
668
|
+
for key in ("type", "code", "message")
|
|
669
|
+
if error.get(key) is not None
|
|
670
|
+
}
|
|
671
|
+
delta = event.get("delta")
|
|
672
|
+
if isinstance(delta, str):
|
|
673
|
+
summary["delta_len"] = len(delta)
|
|
674
|
+
summary["delta_preview"] = delta[:200]
|
|
675
|
+
result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event)
|
|
676
|
+
if result_lengths:
|
|
677
|
+
summary["image_result_lengths"] = result_lengths[:10]
|
|
678
|
+
return summary
|
|
679
|
+
|
|
680
|
+
def _log_codex_response_failure(
|
|
681
|
+
self,
|
|
682
|
+
path: str,
|
|
683
|
+
status_code: int,
|
|
684
|
+
headers: Any,
|
|
685
|
+
payload: Dict[str, Any],
|
|
686
|
+
body: Any,
|
|
687
|
+
) -> None:
|
|
688
|
+
request_headers = self._codex_responses_headers()
|
|
689
|
+
safe_request_headers = {
|
|
690
|
+
key: value for key, value in request_headers.items() if key.lower() != "authorization"
|
|
691
|
+
}
|
|
692
|
+
response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {})
|
|
693
|
+
tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
|
|
694
|
+
logger.warning({
|
|
695
|
+
"event": "codex_responses_http_error",
|
|
696
|
+
"path": path,
|
|
697
|
+
"status_code": status_code,
|
|
698
|
+
"request": {
|
|
699
|
+
"model": payload.get("model"),
|
|
700
|
+
"tool_model": tool.get("model"),
|
|
701
|
+
"tool_action": tool.get("action"),
|
|
702
|
+
"size": tool.get("size"),
|
|
703
|
+
"quality": tool.get("quality"),
|
|
704
|
+
"image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
|
|
705
|
+
"prompt_preview": self._codex_body_preview(
|
|
706
|
+
(((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
|
|
707
|
+
500,
|
|
708
|
+
),
|
|
709
|
+
"headers": safe_request_headers,
|
|
710
|
+
},
|
|
711
|
+
"response": {
|
|
712
|
+
"headers": response_headers,
|
|
713
|
+
"body_preview": self._codex_body_preview(body),
|
|
714
|
+
},
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
@staticmethod
|
|
718
|
+
def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]:
|
|
719
|
+
content_type = str(raw.headers.get("content-type") or "").lower()
|
|
720
|
+
text = raw.read().decode("utf-8", "replace")
|
|
721
|
+
status_code = getattr(raw, "status", None)
|
|
722
|
+
parse_errors: list[str] = []
|
|
723
|
+
events: list[Dict[str, Any]] = []
|
|
724
|
+
if "application/json" in content_type:
|
|
725
|
+
try:
|
|
726
|
+
data = json.loads(text)
|
|
727
|
+
if isinstance(data, dict):
|
|
728
|
+
events.append(data)
|
|
729
|
+
except Exception as exc:
|
|
730
|
+
parse_errors.append(str(exc))
|
|
731
|
+
else:
|
|
732
|
+
lines: list[str] = []
|
|
733
|
+
for line in text.splitlines() + [""]:
|
|
734
|
+
if not line:
|
|
735
|
+
if lines:
|
|
736
|
+
payload_text = "\n".join(lines).strip()
|
|
737
|
+
if payload_text and payload_text != "[DONE]":
|
|
738
|
+
try:
|
|
739
|
+
data = json.loads(payload_text)
|
|
740
|
+
except Exception as exc:
|
|
741
|
+
parse_errors.append(str(exc))
|
|
742
|
+
data = None
|
|
743
|
+
if isinstance(data, dict):
|
|
744
|
+
events.append(data)
|
|
745
|
+
lines = []
|
|
746
|
+
elif line.startswith("data:"):
|
|
747
|
+
lines.append(line[5:].lstrip())
|
|
748
|
+
|
|
749
|
+
event_types: Dict[str, int] = {}
|
|
750
|
+
image_result_lengths: list[int] = []
|
|
751
|
+
for event in events:
|
|
752
|
+
event_type = str(event.get("type") or "<missing>")
|
|
753
|
+
event_types[event_type] = event_types.get(event_type, 0) + 1
|
|
754
|
+
image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event))
|
|
755
|
+
logger.info({
|
|
756
|
+
"event": "codex_responses_response_debug",
|
|
757
|
+
"status_code": status_code,
|
|
758
|
+
"content_type": content_type,
|
|
759
|
+
"response_text_len": len(text),
|
|
760
|
+
"event_count": len(events),
|
|
761
|
+
"event_types": event_types,
|
|
762
|
+
"image_result_lengths": image_result_lengths[:10],
|
|
763
|
+
"parse_error_count": len(parse_errors),
|
|
764
|
+
"parse_errors": parse_errors[:5],
|
|
765
|
+
"event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]],
|
|
766
|
+
"event_previews": [
|
|
767
|
+
OpenAIBackendAPI._codex_body_preview(event, 1500)
|
|
768
|
+
for event in events[:10]
|
|
769
|
+
] if not image_result_lengths else [],
|
|
770
|
+
"body_preview": text[:1000] if not events else "",
|
|
771
|
+
})
|
|
772
|
+
for event in events:
|
|
773
|
+
yield event
|
|
774
|
+
|
|
775
|
+
def iter_codex_image_response_events(
|
|
776
|
+
self,
|
|
777
|
+
prompt: str,
|
|
778
|
+
images: list[str] | None = None,
|
|
779
|
+
size: str | None = None,
|
|
780
|
+
quality: str = "auto",
|
|
781
|
+
) -> Iterator[Dict[str, Any]]:
|
|
782
|
+
if not self.access_token:
|
|
783
|
+
raise RuntimeError("access_token is required for codex image endpoints")
|
|
784
|
+
self._ensure_codex_source_account()
|
|
785
|
+
path = "/backend-api/codex/responses"
|
|
786
|
+
payload = {
|
|
787
|
+
"model": CODEX_RESPONSES_MODEL,
|
|
788
|
+
"instructions": CODEX_RESPONSES_INSTRUCTIONS,
|
|
789
|
+
"store": False,
|
|
790
|
+
"input": self._codex_image_input(prompt, images or []),
|
|
791
|
+
"tools": [{
|
|
792
|
+
"type": "image_generation",
|
|
793
|
+
"model": "gpt-image-2",
|
|
794
|
+
"action": "edit" if images else "generate",
|
|
795
|
+
"size": str(size or "1024x1024"),
|
|
796
|
+
"quality": str(quality or "auto"),
|
|
797
|
+
"output_format": "png",
|
|
798
|
+
}],
|
|
799
|
+
"tool_choice": {"type": "image_generation"},
|
|
800
|
+
"stream": True,
|
|
801
|
+
}
|
|
802
|
+
request = urllib.request.Request(
|
|
803
|
+
self.base_url + path,
|
|
804
|
+
json.dumps(payload).encode(),
|
|
805
|
+
self._codex_responses_headers(),
|
|
806
|
+
method="POST",
|
|
807
|
+
)
|
|
808
|
+
account = account_service.get_account(self.access_token) or {}
|
|
809
|
+
token_payload = account_service._decode_jwt_payload(self.access_token)
|
|
810
|
+
auth_claim = token_payload.get("https://api.openai.com/auth")
|
|
811
|
+
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
|
|
812
|
+
tool = payload["tools"][0]
|
|
813
|
+
logger.info({
|
|
814
|
+
"event": "codex_responses_request_debug",
|
|
815
|
+
"url": self.base_url + path,
|
|
816
|
+
"transport": "urllib.request",
|
|
817
|
+
"timeout_secs": 1200,
|
|
818
|
+
"account_email": str(account.get("email") or "").strip(),
|
|
819
|
+
"source_type": str(account.get("source_type") or "").strip(),
|
|
820
|
+
"account_type": str(account.get("type") or "").strip(),
|
|
821
|
+
"token_claims": {
|
|
822
|
+
"jti": token_payload.get("jti"),
|
|
823
|
+
"iat": token_payload.get("iat"),
|
|
824
|
+
"exp": token_payload.get("exp"),
|
|
825
|
+
"client_id": token_payload.get("client_id"),
|
|
826
|
+
"chatgpt_account_id": auth_claim.get("chatgpt_account_id"),
|
|
827
|
+
"chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"),
|
|
828
|
+
"localhost": auth_claim.get("localhost"),
|
|
829
|
+
},
|
|
830
|
+
"request": {
|
|
831
|
+
"model": payload.get("model"),
|
|
832
|
+
"tool_model": tool.get("model"),
|
|
833
|
+
"tool_action": tool.get("action"),
|
|
834
|
+
"size": tool.get("size"),
|
|
835
|
+
"quality": tool.get("quality"),
|
|
836
|
+
"output_format": tool.get("output_format"),
|
|
837
|
+
"stream": payload.get("stream"),
|
|
838
|
+
"image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
|
|
839
|
+
"prompt_preview": self._codex_body_preview(
|
|
840
|
+
(((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
|
|
841
|
+
500,
|
|
842
|
+
),
|
|
843
|
+
},
|
|
844
|
+
"headers": {
|
|
845
|
+
key: value for key, value in self._codex_responses_headers().items()
|
|
846
|
+
if key.lower() != "authorization"
|
|
847
|
+
},
|
|
848
|
+
})
|
|
849
|
+
try:
|
|
850
|
+
with urllib.request.urlopen(request, timeout=1200) as raw:
|
|
851
|
+
yield from self._iter_codex_response_events(raw)
|
|
852
|
+
except urllib.error.HTTPError as error:
|
|
853
|
+
body_text = error.read().decode("utf-8", "replace")
|
|
854
|
+
body: Any = body_text
|
|
855
|
+
try:
|
|
856
|
+
body = json.loads(body_text)
|
|
857
|
+
except Exception:
|
|
858
|
+
pass
|
|
859
|
+
self._log_codex_response_failure(path, error.code, error.headers, payload, body)
|
|
860
|
+
retry_after_header = error.headers.get("Retry-After") if error.headers else None
|
|
861
|
+
retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
|
|
862
|
+
raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error
|
|
863
|
+
|
|
864
|
+
def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
|
|
865
|
+
"""为图片生成准备 conduit token。"""
|
|
866
|
+
path = "/backend-api/f/conversation/prepare"
|
|
867
|
+
upstream_model, thinking_effort = self._image_model_settings(model)
|
|
868
|
+
payload = {
|
|
869
|
+
"action": "next",
|
|
870
|
+
"fork_from_shared_post": False,
|
|
871
|
+
"parent_message_id": new_uuid(),
|
|
872
|
+
"model": upstream_model,
|
|
873
|
+
"client_prepare_state": "success",
|
|
874
|
+
"timezone_offset_min": -480,
|
|
875
|
+
"timezone": "Asia/Shanghai",
|
|
876
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
877
|
+
"system_hints": ["picture_v2"],
|
|
878
|
+
"partial_query": {
|
|
879
|
+
"id": new_uuid(),
|
|
880
|
+
"author": {"role": "user"},
|
|
881
|
+
"content": {"content_type": "text", "parts": [prompt]},
|
|
882
|
+
},
|
|
883
|
+
"supports_buffering": True,
|
|
884
|
+
"supported_encodings": ["v1"],
|
|
885
|
+
"client_contextual_info": {"app_name": "chatgpt.com"},
|
|
886
|
+
}
|
|
887
|
+
if thinking_effort:
|
|
888
|
+
payload["thinking_effort"] = thinking_effort
|
|
889
|
+
response = self.session.post(
|
|
890
|
+
self.base_url + path,
|
|
891
|
+
headers=self._image_headers(path, requirements),
|
|
892
|
+
json=payload,
|
|
893
|
+
timeout=60,
|
|
894
|
+
)
|
|
895
|
+
ensure_ok(response, path)
|
|
896
|
+
return response.json().get("conduit_token", "")
|
|
897
|
+
|
|
898
|
+
def _decode_image_base64(self, image: str) -> bytes:
|
|
899
|
+
"""把 base64 图片字符串或本地路径解码成二进制。"""
|
|
900
|
+
if (
|
|
901
|
+
image
|
|
902
|
+
and len(image) < 512
|
|
903
|
+
and not image.startswith("data:")
|
|
904
|
+
and "\n" not in image
|
|
905
|
+
and "\r" not in image
|
|
906
|
+
):
|
|
907
|
+
file_path = Path(os.path.expanduser(image))
|
|
908
|
+
if file_path.exists() and file_path.is_file():
|
|
909
|
+
return file_path.read_bytes()
|
|
910
|
+
payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
|
|
911
|
+
return base64.b64decode(payload)
|
|
912
|
+
|
|
913
|
+
def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
|
|
914
|
+
"""上传一张 base64 图片,返回底层文件元数据。"""
|
|
915
|
+
data = self._decode_image_base64(image)
|
|
916
|
+
if (
|
|
917
|
+
image
|
|
918
|
+
and len(image) < 512
|
|
919
|
+
and not image.startswith("data:")
|
|
920
|
+
and "\n" not in image
|
|
921
|
+
and "\r" not in image
|
|
922
|
+
):
|
|
923
|
+
candidate_path = Path(os.path.expanduser(image))
|
|
924
|
+
if candidate_path.exists() and candidate_path.is_file():
|
|
925
|
+
file_name = candidate_path.name
|
|
926
|
+
image = Image.open(BytesIO(data))
|
|
927
|
+
width, height = image.size
|
|
928
|
+
mime_type = Image.MIME.get(image.format, "image/png")
|
|
929
|
+
path = "/backend-api/files"
|
|
930
|
+
response = self.session.post(
|
|
931
|
+
self.base_url + path,
|
|
932
|
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
|
933
|
+
json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
|
|
934
|
+
"height": height},
|
|
935
|
+
timeout=60,
|
|
936
|
+
)
|
|
937
|
+
ensure_ok(response, path)
|
|
938
|
+
upload_meta = response.json()
|
|
939
|
+
response = self.session.put(
|
|
940
|
+
upload_meta["upload_url"],
|
|
941
|
+
headers={
|
|
942
|
+
"Content-Type": mime_type,
|
|
943
|
+
"x-ms-blob-type": "BlockBlob",
|
|
944
|
+
"x-ms-version": "2020-04-08",
|
|
945
|
+
"Origin": self.base_url,
|
|
946
|
+
"Referer": self.base_url + "/",
|
|
947
|
+
"User-Agent": self.user_agent,
|
|
948
|
+
"Accept": "application/json, text/plain, */*",
|
|
949
|
+
"Accept-Language": "en-US,en;q=0.8",
|
|
950
|
+
},
|
|
951
|
+
data=data,
|
|
952
|
+
timeout=120,
|
|
953
|
+
)
|
|
954
|
+
ensure_ok(response, "image_upload")
|
|
955
|
+
path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
|
|
956
|
+
response = self.session.post(
|
|
957
|
+
self.base_url + path,
|
|
958
|
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
|
959
|
+
data="{}",
|
|
960
|
+
timeout=60,
|
|
961
|
+
)
|
|
962
|
+
ensure_ok(response, path)
|
|
963
|
+
return {
|
|
964
|
+
"file_id": upload_meta["file_id"],
|
|
965
|
+
"file_name": file_name,
|
|
966
|
+
"file_size": len(data),
|
|
967
|
+
"mime_type": mime_type,
|
|
968
|
+
"width": width,
|
|
969
|
+
"height": height,
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
|
|
973
|
+
references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
|
|
974
|
+
"""启动图片生成或编辑的 SSE 请求。"""
|
|
975
|
+
upstream_model, thinking_effort = self._image_model_settings(model)
|
|
976
|
+
references = references or []
|
|
977
|
+
parts = [{
|
|
978
|
+
"content_type": "image_asset_pointer",
|
|
979
|
+
"asset_pointer": f"file-service://{item['file_id']}",
|
|
980
|
+
"width": item["width"],
|
|
981
|
+
"height": item["height"],
|
|
982
|
+
"size_bytes": item["file_size"],
|
|
983
|
+
} for item in references]
|
|
984
|
+
parts.append(prompt)
|
|
985
|
+
content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
|
|
986
|
+
"parts": [prompt]}
|
|
987
|
+
metadata = {
|
|
988
|
+
"developer_mode_connector_ids": [],
|
|
989
|
+
"selected_github_repos": [],
|
|
990
|
+
"selected_all_github_repos": False,
|
|
991
|
+
"system_hints": ["picture_v2"],
|
|
992
|
+
"serialization_metadata": {"custom_symbol_offsets": []},
|
|
993
|
+
}
|
|
994
|
+
if references:
|
|
995
|
+
metadata["attachments"] = [{
|
|
996
|
+
"id": item["file_id"],
|
|
997
|
+
"mimeType": item["mime_type"],
|
|
998
|
+
"name": item["file_name"],
|
|
999
|
+
"size": item["file_size"],
|
|
1000
|
+
"width": item["width"],
|
|
1001
|
+
"height": item["height"],
|
|
1002
|
+
} for item in references]
|
|
1003
|
+
payload = {
|
|
1004
|
+
"action": "next",
|
|
1005
|
+
"messages": [{
|
|
1006
|
+
"id": new_uuid(),
|
|
1007
|
+
"author": {"role": "user"},
|
|
1008
|
+
"create_time": time.time(),
|
|
1009
|
+
"content": content,
|
|
1010
|
+
"metadata": metadata,
|
|
1011
|
+
}],
|
|
1012
|
+
"parent_message_id": new_uuid(),
|
|
1013
|
+
"model": upstream_model,
|
|
1014
|
+
"client_prepare_state": "sent",
|
|
1015
|
+
"timezone_offset_min": -480,
|
|
1016
|
+
"timezone": "Asia/Shanghai",
|
|
1017
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
1018
|
+
"enable_message_followups": True,
|
|
1019
|
+
"system_hints": ["picture_v2"],
|
|
1020
|
+
"supports_buffering": True,
|
|
1021
|
+
"supported_encodings": ["v1"],
|
|
1022
|
+
"client_contextual_info": {
|
|
1023
|
+
"is_dark_mode": False,
|
|
1024
|
+
"time_since_loaded": 1200,
|
|
1025
|
+
"page_height": 1072,
|
|
1026
|
+
"page_width": 1724,
|
|
1027
|
+
"pixel_ratio": 1.2,
|
|
1028
|
+
"screen_height": 1440,
|
|
1029
|
+
"screen_width": 2560,
|
|
1030
|
+
"app_name": "chatgpt.com",
|
|
1031
|
+
},
|
|
1032
|
+
"paragen_cot_summary_display_override": "allow",
|
|
1033
|
+
"force_parallel_switch": "auto",
|
|
1034
|
+
}
|
|
1035
|
+
if thinking_effort:
|
|
1036
|
+
payload["thinking_effort"] = thinking_effort
|
|
1037
|
+
path = "/backend-api/f/conversation"
|
|
1038
|
+
response = self.session.post(
|
|
1039
|
+
self.base_url + path,
|
|
1040
|
+
headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
|
|
1041
|
+
json=payload,
|
|
1042
|
+
timeout=300,
|
|
1043
|
+
stream=True,
|
|
1044
|
+
)
|
|
1045
|
+
ensure_ok(response, path)
|
|
1046
|
+
return response
|
|
1047
|
+
|
|
1048
|
+
def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
|
1049
|
+
"""获取完整 conversation 详情。"""
|
|
1050
|
+
path = f"/backend-api/conversation/{conversation_id}"
|
|
1051
|
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
|
1052
|
+
timeout=60)
|
|
1053
|
+
ensure_ok(response, path)
|
|
1054
|
+
return response.json()
|
|
1055
|
+
|
|
1056
|
+
def delete_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
|
1057
|
+
"""删除本地对话记录。"""
|
|
1058
|
+
path = f"/backend-api/conversation/{conversation_id}"
|
|
1059
|
+
headers = self._headers(path, {
|
|
1060
|
+
"Accept": "*/*",
|
|
1061
|
+
"Content-Type": "application/json",
|
|
1062
|
+
"Referer": f"{self.base_url}/c/{conversation_id}",
|
|
1063
|
+
"X-OpenAI-Target-Route": "/backend-api/conversation/{conversation_id}",
|
|
1064
|
+
})
|
|
1065
|
+
response = self.session.patch(
|
|
1066
|
+
self.base_url + path,
|
|
1067
|
+
headers=headers,
|
|
1068
|
+
json={"is_visible": False},
|
|
1069
|
+
timeout=60,
|
|
1070
|
+
)
|
|
1071
|
+
ensure_ok(response, path)
|
|
1072
|
+
return response.json()
|
|
1073
|
+
|
|
1074
|
+
def _list_recent_conversations(self, limit: int = 5, timeout_secs: float = 10.0) -> list[Dict[str, Any]]:
|
|
1075
|
+
"""列出最近的对话列表,按更新时间倒序。
|
|
1076
|
+
|
|
1077
|
+
当 SSE 流太短导致 conversation_id 丢失时,可以通过此方法
|
|
1078
|
+
查找最近创建的对话来恢复 conversation_id。
|
|
1079
|
+
"""
|
|
1080
|
+
path = f"/backend-api/conversations?offset=0&limit={limit}&order=updated&conversation_filter=all"
|
|
1081
|
+
try:
|
|
1082
|
+
response = self.session.get(
|
|
1083
|
+
self.base_url + path,
|
|
1084
|
+
headers=self._headers(path, {"Accept": "application/json"}),
|
|
1085
|
+
timeout=timeout_secs,
|
|
1086
|
+
)
|
|
1087
|
+
ensure_ok(response, path)
|
|
1088
|
+
data = response.json()
|
|
1089
|
+
return data.get("items") or data.get("conversations") or []
|
|
1090
|
+
except Exception as exc:
|
|
1091
|
+
logger.debug({"event": "list_conversations_failed", "error": str(exc)})
|
|
1092
|
+
return []
|
|
1093
|
+
|
|
1094
|
+
def find_conversation_by_prompt(self, prompt: str, started_at: float, timeout_secs: float = 10.0) -> str:
|
|
1095
|
+
"""根据 prompt 和开始时间,从最近对话列表中查找匹配的 conversation_id。
|
|
1096
|
+
|
|
1097
|
+
当 SSE 流太短导致 conversation_id 丢失时,使用此方法恢复。
|
|
1098
|
+
通过对比 prompt 关键词和时间戳来匹配最可能的对话。
|
|
1099
|
+
|
|
1100
|
+
参数:
|
|
1101
|
+
prompt: 用户输入的 prompt 文本
|
|
1102
|
+
started_at: 请求开始的时间戳(epoch seconds)
|
|
1103
|
+
timeout_secs: 请求超时秒数
|
|
1104
|
+
|
|
1105
|
+
返回:
|
|
1106
|
+
匹配的 conversation_id,如果未找到返回空字符串
|
|
1107
|
+
"""
|
|
1108
|
+
items = self._list_recent_conversations(limit=10, timeout_secs=timeout_secs)
|
|
1109
|
+
if not items:
|
|
1110
|
+
return ""
|
|
1111
|
+
# 筛选在 started_at 之前或附近创建的对话(最多往前 5 分钟)
|
|
1112
|
+
# ChatGPT 的 updated_at 通常晚于实际请求时间
|
|
1113
|
+
prompt_lower = str(prompt or "").lower().strip()
|
|
1114
|
+
best_match = ""
|
|
1115
|
+
best_score = 0.0
|
|
1116
|
+
for item in items:
|
|
1117
|
+
# item 可能是完整的 conversation 对象或摘要
|
|
1118
|
+
conv_id = str(item.get("id") or item.get("conversation_id") or "")
|
|
1119
|
+
if not conv_id:
|
|
1120
|
+
continue
|
|
1121
|
+
# 检查时间范围:对话的 updated_at 应该在请求开始时间之后(或附近)
|
|
1122
|
+
updated_at = float(item.get("update_time") or item.get("updated_at") or 0)
|
|
1123
|
+
if updated_at and started_at and (updated_at < started_at - 30 or updated_at > started_at + 600):
|
|
1124
|
+
continue
|
|
1125
|
+
# 匹配 prompt 关键词
|
|
1126
|
+
title = str(item.get("title") or "").lower()
|
|
1127
|
+
# 计算匹配分数
|
|
1128
|
+
score = 0.0
|
|
1129
|
+
if prompt_lower and title:
|
|
1130
|
+
# 简单的关键词匹配
|
|
1131
|
+
prompt_words = set(prompt_lower.split())
|
|
1132
|
+
title_words = set(title.split())
|
|
1133
|
+
common = prompt_words & title_words
|
|
1134
|
+
if common:
|
|
1135
|
+
score = len(common) / max(len(prompt_words), 1)
|
|
1136
|
+
# 图生图通常标题为 "Image" 开头
|
|
1137
|
+
if title.startswith("image"):
|
|
1138
|
+
score += 0.3
|
|
1139
|
+
if score > best_score:
|
|
1140
|
+
best_score = score
|
|
1141
|
+
best_match = conv_id
|
|
1142
|
+
if best_match and best_score > 0.1:
|
|
1143
|
+
logger.info({
|
|
1144
|
+
"event": "conversation_prompt_match_found",
|
|
1145
|
+
"conversation_id": best_match,
|
|
1146
|
+
"match_score": round(best_score, 2),
|
|
1147
|
+
})
|
|
1148
|
+
return best_match
|
|
1149
|
+
# 如果没有标题匹配,返回最新的对话(时间最近的)
|
|
1150
|
+
for item in items:
|
|
1151
|
+
conv_id = str(item.get("id") or item.get("conversation_id") or "")
|
|
1152
|
+
updated_at = float(item.get("update_time") or item.get("updated_at") or 0)
|
|
1153
|
+
if conv_id and updated_at and started_at and updated_at >= started_at - 30:
|
|
1154
|
+
logger.info({
|
|
1155
|
+
"event": "conversation_latest_match",
|
|
1156
|
+
"conversation_id": conv_id,
|
|
1157
|
+
"updated_at": updated_at,
|
|
1158
|
+
})
|
|
1159
|
+
return conv_id
|
|
1160
|
+
return ""
|
|
1161
|
+
|
|
1162
|
+
@staticmethod
|
|
1163
|
+
def _editable_prompt(fixed_prompt: str, user_prompt_text: str) -> str:
|
|
1164
|
+
extra = str(user_prompt_text or "").strip()
|
|
1165
|
+
return fixed_prompt if not extra else fixed_prompt + "\n\n以下是用户补充需求,请直接结合执行:\n" + extra
|
|
1166
|
+
|
|
1167
|
+
def export_ppt_zip(
|
|
1168
|
+
self,
|
|
1169
|
+
base64_images: list[str] | None,
|
|
1170
|
+
prompt: str,
|
|
1171
|
+
output_dir: str | Path = EDITABLE_FILE_PPT_OUTPUT_DIR,
|
|
1172
|
+
timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS,
|
|
1173
|
+
poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS,
|
|
1174
|
+
) -> EditableFileExportResult:
|
|
1175
|
+
return self._export_editable_file_zip(
|
|
1176
|
+
base64_images or [],
|
|
1177
|
+
self._editable_prompt(EDITABLE_FILE_PPT_PROMPT, prompt),
|
|
1178
|
+
output_dir,
|
|
1179
|
+
primary_label="ppt",
|
|
1180
|
+
primary_suffixes=(".ppt", ".pptx"),
|
|
1181
|
+
primary_mime_types=EDITABLE_PPT_MIME_TYPES,
|
|
1182
|
+
primary_mime_keywords=("presentationml.presentation", "ms-powerpoint"),
|
|
1183
|
+
primary_default_extension=".pptx",
|
|
1184
|
+
export_file_re=EDITABLE_PPT_EXPORT_FILE_RE,
|
|
1185
|
+
timeout_secs=timeout_secs,
|
|
1186
|
+
poll_interval_secs=poll_interval_secs,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
def export_psd_zip(
|
|
1190
|
+
self,
|
|
1191
|
+
base64_images: list[str],
|
|
1192
|
+
prompt: str,
|
|
1193
|
+
output_dir: str | Path = EDITABLE_FILE_PSD_OUTPUT_DIR,
|
|
1194
|
+
timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS,
|
|
1195
|
+
poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS,
|
|
1196
|
+
) -> EditableFileExportResult:
|
|
1197
|
+
if not base64_images:
|
|
1198
|
+
raise ValueError("base64_images is empty")
|
|
1199
|
+
return self._export_editable_file_zip(
|
|
1200
|
+
base64_images,
|
|
1201
|
+
self._editable_prompt(EDITABLE_FILE_PSD_PROMPT, prompt),
|
|
1202
|
+
output_dir,
|
|
1203
|
+
primary_label="psd",
|
|
1204
|
+
primary_suffixes=(".psd",),
|
|
1205
|
+
primary_mime_types=EDITABLE_PSD_MIME_TYPES,
|
|
1206
|
+
primary_mime_keywords=("photoshop",),
|
|
1207
|
+
primary_default_extension=".psd",
|
|
1208
|
+
export_file_re=EDITABLE_PSD_EXPORT_FILE_RE,
|
|
1209
|
+
timeout_secs=timeout_secs,
|
|
1210
|
+
poll_interval_secs=poll_interval_secs,
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
def _export_editable_file_zip(
|
|
1214
|
+
self,
|
|
1215
|
+
base64_images: list[str],
|
|
1216
|
+
prompt: str,
|
|
1217
|
+
output_dir: str | Path,
|
|
1218
|
+
*,
|
|
1219
|
+
primary_label: str,
|
|
1220
|
+
primary_suffixes: tuple[str, ...],
|
|
1221
|
+
primary_mime_types: set[str],
|
|
1222
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1223
|
+
primary_default_extension: str,
|
|
1224
|
+
export_file_re: re.Pattern[str],
|
|
1225
|
+
timeout_secs: float,
|
|
1226
|
+
poll_interval_secs: float,
|
|
1227
|
+
) -> EditableFileExportResult:
|
|
1228
|
+
if not self.access_token:
|
|
1229
|
+
raise RuntimeError("access_token is required for editable file export")
|
|
1230
|
+
self.client_version = EDITABLE_FILE_CLIENT_VERSION
|
|
1231
|
+
self.client_build_number = EDITABLE_FILE_CLIENT_BUILD_NUMBER
|
|
1232
|
+
self.session.headers["OAI-Client-Version"] = EDITABLE_FILE_CLIENT_VERSION
|
|
1233
|
+
self.session.headers["OAI-Client-Build-Number"] = EDITABLE_FILE_CLIENT_BUILD_NUMBER
|
|
1234
|
+
output_path = Path(output_dir).expanduser().resolve()
|
|
1235
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
1236
|
+
uploaded = [self._upload_editable_base64_image(item, index) for index, item in enumerate(base64_images, start=1)]
|
|
1237
|
+
conduit_token = self._prepare_editable_conversation(prompt, [item["mime_type"] for item in uploaded])
|
|
1238
|
+
conversation_id = self._run_editable_conversation(prompt, uploaded, conduit_token)
|
|
1239
|
+
artifacts = self._wait_editable_output_artifacts(
|
|
1240
|
+
conversation_id,
|
|
1241
|
+
primary_label,
|
|
1242
|
+
primary_suffixes,
|
|
1243
|
+
primary_mime_types,
|
|
1244
|
+
primary_mime_keywords,
|
|
1245
|
+
export_file_re,
|
|
1246
|
+
timeout_secs,
|
|
1247
|
+
poll_interval_secs,
|
|
1248
|
+
)
|
|
1249
|
+
downloaded = [self._download_editable_artifact(
|
|
1250
|
+
conversation_id,
|
|
1251
|
+
item,
|
|
1252
|
+
output_path,
|
|
1253
|
+
primary_mime_types,
|
|
1254
|
+
primary_mime_keywords,
|
|
1255
|
+
primary_default_extension,
|
|
1256
|
+
) for item in artifacts]
|
|
1257
|
+
primary_path = next((item for item in downloaded if item.suffix.lower() in primary_suffixes), None)
|
|
1258
|
+
zip_path = next((item for item in downloaded if item.suffix.lower() == ".zip"), None)
|
|
1259
|
+
if not primary_path or not zip_path:
|
|
1260
|
+
raise RuntimeError(f"download finished but did not get both {primary_label} and zip files: {downloaded}")
|
|
1261
|
+
return EditableFileExportResult(conversation_id=conversation_id, primary_path=primary_path, zip_path=zip_path)
|
|
1262
|
+
|
|
1263
|
+
def _upload_editable_base64_image(self, base64_image: str, index: int) -> Dict[str, Any]:
|
|
1264
|
+
data, file_name, mime_type, width, height = self._decode_editable_base64_image(base64_image, index)
|
|
1265
|
+
path = "/backend-api/files"
|
|
1266
|
+
response = self.session.post(
|
|
1267
|
+
self.base_url + path,
|
|
1268
|
+
headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}),
|
|
1269
|
+
json={
|
|
1270
|
+
"file_name": file_name,
|
|
1271
|
+
"file_size": len(data),
|
|
1272
|
+
"use_case": "multimodal",
|
|
1273
|
+
"timezone_offset_min": -480,
|
|
1274
|
+
"reset_rate_limits": False,
|
|
1275
|
+
"store_in_library": True,
|
|
1276
|
+
"library_persistence_mode": "opportunistic",
|
|
1277
|
+
},
|
|
1278
|
+
timeout=60,
|
|
1279
|
+
)
|
|
1280
|
+
ensure_ok(response, path)
|
|
1281
|
+
payload = response.json()
|
|
1282
|
+
upload_url = str(payload.get("upload_url") or "")
|
|
1283
|
+
file_id = str(payload.get("file_id") or "")
|
|
1284
|
+
if not upload_url or not file_id:
|
|
1285
|
+
raise RuntimeError(f"invalid upload response: {payload}")
|
|
1286
|
+
response = self.session.put(
|
|
1287
|
+
upload_url,
|
|
1288
|
+
headers={
|
|
1289
|
+
"Content-Type": mime_type,
|
|
1290
|
+
"x-ms-blob-type": "BlockBlob",
|
|
1291
|
+
"x-ms-version": "2020-04-08",
|
|
1292
|
+
"Origin": self.base_url,
|
|
1293
|
+
"Referer": self.base_url + "/",
|
|
1294
|
+
"User-Agent": self.user_agent,
|
|
1295
|
+
"Accept": "application/json, text/plain, */*",
|
|
1296
|
+
"Accept-Language": "en-US,en;q=0.8",
|
|
1297
|
+
},
|
|
1298
|
+
data=data,
|
|
1299
|
+
timeout=120,
|
|
1300
|
+
)
|
|
1301
|
+
ensure_ok(response, "image_upload")
|
|
1302
|
+
path = f"/backend-api/files/{file_id}/uploaded"
|
|
1303
|
+
response = self.session.post(
|
|
1304
|
+
self.base_url + path,
|
|
1305
|
+
headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}),
|
|
1306
|
+
data="{}",
|
|
1307
|
+
timeout=60,
|
|
1308
|
+
)
|
|
1309
|
+
ensure_ok(response, path)
|
|
1310
|
+
return {
|
|
1311
|
+
"file_id": file_id,
|
|
1312
|
+
"library_file_id": str(payload.get("library_file_id") or ""),
|
|
1313
|
+
"file_name": file_name,
|
|
1314
|
+
"file_size": len(data),
|
|
1315
|
+
"mime_type": mime_type,
|
|
1316
|
+
"width": width,
|
|
1317
|
+
"height": height,
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
def _decode_editable_base64_image(self, base64_image: str, index: int) -> tuple[bytes, str, str, int, int]:
|
|
1321
|
+
raw = str(base64_image or "").strip()
|
|
1322
|
+
if not raw:
|
|
1323
|
+
raise ValueError("base64 image is empty")
|
|
1324
|
+
mime_type = ""
|
|
1325
|
+
payload = raw
|
|
1326
|
+
match = re.match(r"^data:([^;]+);base64,(.*)$", raw, re.IGNORECASE | re.DOTALL)
|
|
1327
|
+
if match:
|
|
1328
|
+
mime_type = str(match.group(1) or "").strip().lower()
|
|
1329
|
+
payload = str(match.group(2) or "").strip()
|
|
1330
|
+
data = base64.b64decode(payload)
|
|
1331
|
+
image = Image.open(BytesIO(data))
|
|
1332
|
+
image.load()
|
|
1333
|
+
width, height = image.size
|
|
1334
|
+
mime_type = Image.MIME.get(image.format, mime_type or "image/png")
|
|
1335
|
+
extension = mimetypes.guess_extension(mime_type) or ".png"
|
|
1336
|
+
return data, f"image_{index}{extension}", mime_type, width, height
|
|
1337
|
+
|
|
1338
|
+
def _prepare_editable_conversation(self, prompt: str, attachment_mime_types: list[str]) -> str:
|
|
1339
|
+
path = "/backend-api/f/conversation/prepare"
|
|
1340
|
+
payload: Dict[str, Any] = {
|
|
1341
|
+
"action": "next",
|
|
1342
|
+
"fork_from_shared_post": False,
|
|
1343
|
+
"parent_message_id": "client-created-root",
|
|
1344
|
+
"model": EDITABLE_FILE_MODEL,
|
|
1345
|
+
"client_prepare_state": "success",
|
|
1346
|
+
"timezone_offset_min": -480,
|
|
1347
|
+
"timezone": "Asia/Shanghai",
|
|
1348
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
1349
|
+
"system_hints": [],
|
|
1350
|
+
"partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}},
|
|
1351
|
+
"supports_buffering": True,
|
|
1352
|
+
"supported_encodings": ["v1"],
|
|
1353
|
+
"client_contextual_info": {"app_name": "chatgpt.com"},
|
|
1354
|
+
"thinking_effort": EDITABLE_FILE_THINKING_EFFORT,
|
|
1355
|
+
}
|
|
1356
|
+
if attachment_mime_types:
|
|
1357
|
+
payload["attachment_mime_types"] = attachment_mime_types
|
|
1358
|
+
response = self.session.post(
|
|
1359
|
+
self.base_url + path,
|
|
1360
|
+
headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}),
|
|
1361
|
+
json=payload,
|
|
1362
|
+
timeout=60,
|
|
1363
|
+
)
|
|
1364
|
+
ensure_ok(response, path)
|
|
1365
|
+
conduit_token = str(response.json().get("conduit_token") or "")
|
|
1366
|
+
if not conduit_token:
|
|
1367
|
+
raise RuntimeError(f"missing conduit_token: {response.text}")
|
|
1368
|
+
return conduit_token
|
|
1369
|
+
|
|
1370
|
+
def _run_editable_conversation(self, prompt: str, uploaded: list[Dict[str, Any]], conduit_token: str) -> str:
|
|
1371
|
+
self._bootstrap()
|
|
1372
|
+
requirements = self._get_chat_requirements()
|
|
1373
|
+
message: Dict[str, Any] = {"id": new_uuid(), "author": {"role": "user"}, "create_time": time.time()}
|
|
1374
|
+
if uploaded:
|
|
1375
|
+
parts = [{
|
|
1376
|
+
"content_type": "image_asset_pointer",
|
|
1377
|
+
"asset_pointer": f"sediment://{item['file_id']}",
|
|
1378
|
+
"size_bytes": item["file_size"],
|
|
1379
|
+
"width": item["width"],
|
|
1380
|
+
"height": item["height"],
|
|
1381
|
+
} for item in uploaded]
|
|
1382
|
+
parts.append(prompt)
|
|
1383
|
+
message["content"] = {"content_type": "multimodal_text", "parts": parts}
|
|
1384
|
+
message["metadata"] = {
|
|
1385
|
+
"attachments": [{
|
|
1386
|
+
"id": item["file_id"],
|
|
1387
|
+
"size": item["file_size"],
|
|
1388
|
+
"name": item["file_name"],
|
|
1389
|
+
"mime_type": item["mime_type"],
|
|
1390
|
+
"width": item["width"],
|
|
1391
|
+
"height": item["height"],
|
|
1392
|
+
"source": "library",
|
|
1393
|
+
"library_file_id": item["library_file_id"],
|
|
1394
|
+
"is_big_paste": False,
|
|
1395
|
+
} for item in uploaded],
|
|
1396
|
+
"developer_mode_connector_ids": [],
|
|
1397
|
+
"selected_sources": [],
|
|
1398
|
+
"selected_github_repos": [],
|
|
1399
|
+
"selected_all_github_repos": False,
|
|
1400
|
+
"serialization_metadata": {"custom_symbol_offsets": []},
|
|
1401
|
+
}
|
|
1402
|
+
else:
|
|
1403
|
+
message["content"] = {"content_type": "text", "parts": [prompt]}
|
|
1404
|
+
path = "/backend-api/f/conversation"
|
|
1405
|
+
response = self.session.post(
|
|
1406
|
+
self.base_url + path,
|
|
1407
|
+
headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
|
|
1408
|
+
json={
|
|
1409
|
+
"action": "next",
|
|
1410
|
+
"messages": [message],
|
|
1411
|
+
"parent_message_id": "client-created-root",
|
|
1412
|
+
"model": EDITABLE_FILE_MODEL,
|
|
1413
|
+
"client_prepare_state": "sent",
|
|
1414
|
+
"timezone_offset_min": -480,
|
|
1415
|
+
"timezone": "Asia/Shanghai",
|
|
1416
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
1417
|
+
"enable_message_followups": True,
|
|
1418
|
+
"system_hints": [],
|
|
1419
|
+
"supports_buffering": True,
|
|
1420
|
+
"supported_encodings": ["v1"],
|
|
1421
|
+
"client_contextual_info": {
|
|
1422
|
+
"is_dark_mode": False,
|
|
1423
|
+
"time_since_loaded": 401,
|
|
1424
|
+
"page_height": 1138,
|
|
1425
|
+
"page_width": 803,
|
|
1426
|
+
"pixel_ratio": 2,
|
|
1427
|
+
"screen_height": 1440,
|
|
1428
|
+
"screen_width": 2560,
|
|
1429
|
+
"app_name": "chatgpt.com",
|
|
1430
|
+
},
|
|
1431
|
+
"paragen_cot_summary_display_override": "allow",
|
|
1432
|
+
"force_parallel_switch": "auto",
|
|
1433
|
+
"thinking_effort": EDITABLE_FILE_THINKING_EFFORT,
|
|
1434
|
+
},
|
|
1435
|
+
timeout=300,
|
|
1436
|
+
stream=True,
|
|
1437
|
+
)
|
|
1438
|
+
ensure_ok(response, path)
|
|
1439
|
+
conversation_id = ""
|
|
1440
|
+
try:
|
|
1441
|
+
for payload in iter_sse_payloads(response):
|
|
1442
|
+
if payload == "[DONE]":
|
|
1443
|
+
break
|
|
1444
|
+
conversation_id = conversation_id or self._find_editable_value(payload, "conversation_id")
|
|
1445
|
+
finally:
|
|
1446
|
+
response.close()
|
|
1447
|
+
if not conversation_id:
|
|
1448
|
+
raise RuntimeError("conversation_id not found in stream")
|
|
1449
|
+
return conversation_id
|
|
1450
|
+
|
|
1451
|
+
def _wait_editable_output_artifacts(
|
|
1452
|
+
self,
|
|
1453
|
+
conversation_id: str,
|
|
1454
|
+
primary_label: str,
|
|
1455
|
+
primary_suffixes: tuple[str, ...],
|
|
1456
|
+
primary_mime_types: set[str],
|
|
1457
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1458
|
+
export_file_re: re.Pattern[str],
|
|
1459
|
+
timeout_secs: float,
|
|
1460
|
+
poll_interval_secs: float,
|
|
1461
|
+
) -> list[EditableFileArtifact]:
|
|
1462
|
+
deadline = time.time() + timeout_secs
|
|
1463
|
+
while time.time() < deadline:
|
|
1464
|
+
try:
|
|
1465
|
+
conversation = self._get_editable_conversation_detail(conversation_id)
|
|
1466
|
+
except UpstreamHTTPError as exc:
|
|
1467
|
+
if exc.status_code in {404, 409, 423, 429, 500, 502, 503, 504}:
|
|
1468
|
+
time.sleep(poll_interval_secs)
|
|
1469
|
+
continue
|
|
1470
|
+
raise
|
|
1471
|
+
targeted = self._pick_editable_target_artifacts(
|
|
1472
|
+
self._extract_editable_artifacts(conversation, export_file_re),
|
|
1473
|
+
primary_suffixes,
|
|
1474
|
+
primary_mime_types,
|
|
1475
|
+
primary_mime_keywords,
|
|
1476
|
+
)
|
|
1477
|
+
if targeted:
|
|
1478
|
+
return targeted
|
|
1479
|
+
time.sleep(poll_interval_secs)
|
|
1480
|
+
raise RuntimeError(f"timed out waiting for {primary_label}/zip outputs")
|
|
1481
|
+
|
|
1482
|
+
def _get_editable_conversation_detail(self, conversation_id: str) -> Dict[str, Any]:
|
|
1483
|
+
path = f"/backend-api/conversation/{conversation_id}"
|
|
1484
|
+
response = self.session.get(self.base_url + path, headers=self._editable_conversation_document_headers(path, conversation_id), timeout=60)
|
|
1485
|
+
ensure_ok(response, path)
|
|
1486
|
+
return response.json()
|
|
1487
|
+
|
|
1488
|
+
def _editable_browser_headers(self, path: str, conversation_id: str) -> Dict[str, str]:
|
|
1489
|
+
headers = self._headers(path, {"Accept": "*/*"})
|
|
1490
|
+
headers["Referer"] = f"{self.base_url}/c/{conversation_id}"
|
|
1491
|
+
return headers
|
|
1492
|
+
|
|
1493
|
+
def _editable_conversation_document_headers(self, path: str, conversation_id: str) -> Dict[str, str]:
|
|
1494
|
+
headers = self._editable_browser_headers(path, conversation_id)
|
|
1495
|
+
headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}"
|
|
1496
|
+
return headers
|
|
1497
|
+
|
|
1498
|
+
def _extract_editable_artifacts(self, conversation: Dict[str, Any], export_file_re: re.Pattern[str]) -> list[EditableFileArtifact]:
|
|
1499
|
+
artifacts: dict[str, EditableFileArtifact] = {}
|
|
1500
|
+
for node in sorted((conversation.get("mapping") or {}).values(), key=lambda item: float(((item or {}).get("message") or {}).get("create_time") or 0.0)):
|
|
1501
|
+
message = (node or {}).get("message") or {}
|
|
1502
|
+
message_id = str(message.get("id") or "")
|
|
1503
|
+
author_role = str(((message.get("author") or {}).get("role") or "")).strip()
|
|
1504
|
+
if author_role not in {"assistant", "tool"}:
|
|
1505
|
+
continue
|
|
1506
|
+
create_time = float(message.get("create_time") or 0.0)
|
|
1507
|
+
message_text = self._editable_message_text(message)
|
|
1508
|
+
for artifact in self._extract_editable_message_artifacts(message, message_id, author_role, create_time, export_file_re):
|
|
1509
|
+
key = artifact.attachment_id or artifact.file_id or artifact.name or artifact.sandbox_path
|
|
1510
|
+
if key:
|
|
1511
|
+
artifacts[key] = self._merge_editable_artifact(artifacts.get(key), artifact)
|
|
1512
|
+
for export_path in self._extract_editable_export_paths(message_text, export_file_re):
|
|
1513
|
+
inferred = EditableFileArtifact(name=Path(export_path).name, create_time=create_time, author_role=author_role, sandbox_path=export_path, message_id=message_id)
|
|
1514
|
+
artifacts[export_path] = self._merge_editable_artifact(artifacts.get(export_path), inferred)
|
|
1515
|
+
return sorted(artifacts.values(), key=lambda item: item.create_time)
|
|
1516
|
+
|
|
1517
|
+
def _extract_editable_message_artifacts(
|
|
1518
|
+
self,
|
|
1519
|
+
message: Dict[str, Any],
|
|
1520
|
+
message_id: str,
|
|
1521
|
+
author_role: str,
|
|
1522
|
+
create_time: float,
|
|
1523
|
+
export_file_re: re.Pattern[str],
|
|
1524
|
+
) -> list[EditableFileArtifact]:
|
|
1525
|
+
artifacts: list[EditableFileArtifact] = []
|
|
1526
|
+
for item in (message.get("metadata") or {}).get("attachments") or []:
|
|
1527
|
+
artifact = self._editable_artifact_from_dict(item, message_id, author_role, create_time, export_file_re)
|
|
1528
|
+
if artifact:
|
|
1529
|
+
artifacts.append(artifact)
|
|
1530
|
+
for obj in self._walk_search_dicts(message):
|
|
1531
|
+
artifact = self._editable_artifact_from_dict(obj, message_id, author_role, create_time, export_file_re)
|
|
1532
|
+
if artifact:
|
|
1533
|
+
artifacts.append(artifact)
|
|
1534
|
+
return artifacts
|
|
1535
|
+
|
|
1536
|
+
def _editable_artifact_from_dict(
|
|
1537
|
+
self,
|
|
1538
|
+
payload: Dict[str, Any],
|
|
1539
|
+
message_id: str,
|
|
1540
|
+
author_role: str,
|
|
1541
|
+
create_time: float,
|
|
1542
|
+
export_file_re: re.Pattern[str],
|
|
1543
|
+
) -> EditableFileArtifact | None:
|
|
1544
|
+
if not ({"id", "file_id", "asset_pointer", "name", "file_name", "filename", "mime_type", "mimeType"} & set(payload.keys())):
|
|
1545
|
+
return None
|
|
1546
|
+
attachment_id = self._match_editable_file_id(str(payload.get("id") or ""))
|
|
1547
|
+
file_id = self._match_editable_file_id(str(payload.get("file_id") or ""))
|
|
1548
|
+
name = self._sanitize_editable_filename(str(payload.get("name") or payload.get("file_name") or payload.get("filename") or payload.get("title") or "").strip())
|
|
1549
|
+
mime_type = self._clean_editable_mime_type(payload.get("mime_type") or payload.get("mimeType") or "")
|
|
1550
|
+
for asset_id in EDITABLE_ASSET_POINTER_RE.findall(str(payload.get("asset_pointer") or "")):
|
|
1551
|
+
attachment_id = attachment_id or asset_id
|
|
1552
|
+
file_id = file_id or asset_id
|
|
1553
|
+
if not attachment_id or not file_id:
|
|
1554
|
+
ids = self._extract_editable_file_ids(json.dumps(payload, ensure_ascii=False))
|
|
1555
|
+
attachment_id = attachment_id or (ids[0] if ids else "")
|
|
1556
|
+
file_id = file_id or (ids[0] if ids else "")
|
|
1557
|
+
if not attachment_id and not file_id:
|
|
1558
|
+
return None
|
|
1559
|
+
return EditableFileArtifact(
|
|
1560
|
+
attachment_id=attachment_id,
|
|
1561
|
+
file_id=file_id,
|
|
1562
|
+
name=name,
|
|
1563
|
+
mime_type=mime_type,
|
|
1564
|
+
create_time=create_time,
|
|
1565
|
+
author_role=author_role,
|
|
1566
|
+
sandbox_path=(self._extract_editable_export_paths(payload, export_file_re) or [""])[0],
|
|
1567
|
+
message_id=message_id,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
def _pick_editable_target_artifacts(
|
|
1571
|
+
self,
|
|
1572
|
+
artifacts: list[EditableFileArtifact],
|
|
1573
|
+
primary_suffixes: tuple[str, ...],
|
|
1574
|
+
primary_mime_types: set[str],
|
|
1575
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1576
|
+
) -> list[EditableFileArtifact]:
|
|
1577
|
+
primary = next((item for item in reversed(artifacts) if self._looks_like_editable_primary(item, primary_suffixes, primary_mime_types, primary_mime_keywords)), None)
|
|
1578
|
+
zip_item = next((item for item in reversed(artifacts) if self._looks_like_editable_zip(item)), None)
|
|
1579
|
+
return [primary, zip_item] if primary and zip_item else []
|
|
1580
|
+
|
|
1581
|
+
def _download_editable_artifact(
|
|
1582
|
+
self,
|
|
1583
|
+
conversation_id: str,
|
|
1584
|
+
artifact: EditableFileArtifact,
|
|
1585
|
+
output_dir: Path,
|
|
1586
|
+
primary_mime_types: set[str],
|
|
1587
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1588
|
+
primary_default_extension: str,
|
|
1589
|
+
) -> Path:
|
|
1590
|
+
download_url = self._resolve_editable_download_url(conversation_id, artifact)
|
|
1591
|
+
if not download_url:
|
|
1592
|
+
raise RuntimeError(f"download url not found for artifact: {artifact}")
|
|
1593
|
+
response = self.session.get(download_url, timeout=300)
|
|
1594
|
+
ensure_ok(response, "artifact_download")
|
|
1595
|
+
content_type = self._clean_editable_mime_type(response.headers.get("Content-Type") or artifact.mime_type)
|
|
1596
|
+
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)
|
|
1597
|
+
target_path = self._unique_editable_path(output_dir / file_name)
|
|
1598
|
+
target_path.write_bytes(response.content)
|
|
1599
|
+
return target_path
|
|
1600
|
+
|
|
1601
|
+
def _resolve_editable_download_url(self, conversation_id: str, artifact: EditableFileArtifact) -> str:
|
|
1602
|
+
ids: list[str] = []
|
|
1603
|
+
for item in (artifact.attachment_id, artifact.file_id):
|
|
1604
|
+
if item and item not in ids:
|
|
1605
|
+
ids.append(item)
|
|
1606
|
+
if artifact.sandbox_path and artifact.message_id:
|
|
1607
|
+
path = f"/backend-api/conversation/{conversation_id}/interpreter/download"
|
|
1608
|
+
response = self.session.get(
|
|
1609
|
+
self.base_url + path,
|
|
1610
|
+
headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/interpreter/download"),
|
|
1611
|
+
params={"message_id": artifact.message_id, "sandbox_path": artifact.sandbox_path},
|
|
1612
|
+
timeout=60,
|
|
1613
|
+
)
|
|
1614
|
+
if 200 <= response.status_code < 300:
|
|
1615
|
+
url = self._download_url_from_response(response)
|
|
1616
|
+
if url:
|
|
1617
|
+
return url
|
|
1618
|
+
for attachment_id in ids:
|
|
1619
|
+
path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
|
|
1620
|
+
response = self.session.get(
|
|
1621
|
+
self.base_url + path,
|
|
1622
|
+
headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"),
|
|
1623
|
+
timeout=60,
|
|
1624
|
+
)
|
|
1625
|
+
if 200 <= response.status_code < 300:
|
|
1626
|
+
url = self._download_url_from_response(response)
|
|
1627
|
+
if url:
|
|
1628
|
+
return url
|
|
1629
|
+
for file_id in ids:
|
|
1630
|
+
path = f"/backend-api/files/download/{file_id}"
|
|
1631
|
+
response = self.session.get(
|
|
1632
|
+
self.base_url + path,
|
|
1633
|
+
headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"),
|
|
1634
|
+
params={"post_id": "", "inline": "false"},
|
|
1635
|
+
timeout=60,
|
|
1636
|
+
)
|
|
1637
|
+
if 200 <= response.status_code < 300:
|
|
1638
|
+
url = self._download_url_from_response(response)
|
|
1639
|
+
if url:
|
|
1640
|
+
return url
|
|
1641
|
+
for file_id in ids:
|
|
1642
|
+
path = f"/backend-api/files/{file_id}/download"
|
|
1643
|
+
response = self.session.get(
|
|
1644
|
+
self.base_url + path,
|
|
1645
|
+
headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"),
|
|
1646
|
+
timeout=60,
|
|
1647
|
+
)
|
|
1648
|
+
if 200 <= response.status_code < 300:
|
|
1649
|
+
url = self._download_url_from_response(response)
|
|
1650
|
+
if url:
|
|
1651
|
+
return url
|
|
1652
|
+
return ""
|
|
1653
|
+
|
|
1654
|
+
def _editable_download_headers(self, path: str, conversation_id: str, route: str) -> Dict[str, str]:
|
|
1655
|
+
headers = self._editable_browser_headers(path, conversation_id)
|
|
1656
|
+
headers["X-OpenAI-Target-Route"] = route
|
|
1657
|
+
return headers
|
|
1658
|
+
|
|
1659
|
+
@staticmethod
|
|
1660
|
+
def _download_url_from_response(response: Any) -> str:
|
|
1661
|
+
try:
|
|
1662
|
+
payload = response.json()
|
|
1663
|
+
except Exception:
|
|
1664
|
+
payload = {}
|
|
1665
|
+
return str(payload.get("download_url") or payload.get("url") or "")
|
|
1666
|
+
|
|
1667
|
+
def _resolve_editable_output_name(
|
|
1668
|
+
self,
|
|
1669
|
+
artifact: EditableFileArtifact,
|
|
1670
|
+
final_url: str,
|
|
1671
|
+
content_disposition: str | None,
|
|
1672
|
+
content_type: str,
|
|
1673
|
+
primary_mime_types: set[str],
|
|
1674
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1675
|
+
primary_default_extension: str,
|
|
1676
|
+
) -> str:
|
|
1677
|
+
file_name = self._sanitize_editable_filename(artifact.name)
|
|
1678
|
+
if not file_name and artifact.sandbox_path:
|
|
1679
|
+
file_name = self._sanitize_editable_filename(Path(artifact.sandbox_path).name)
|
|
1680
|
+
if not file_name:
|
|
1681
|
+
file_name = self._sanitize_editable_filename(self._editable_filename_from_content_disposition(content_disposition or ""))
|
|
1682
|
+
if not file_name:
|
|
1683
|
+
file_name = self._sanitize_editable_filename(Path(urlparse(final_url).path).name)
|
|
1684
|
+
extension = self._editable_extension_from_mime_type(content_type, primary_mime_types, primary_mime_keywords, primary_default_extension)
|
|
1685
|
+
return f"artifact{extension}" if not file_name else (file_name if Path(file_name).suffix else file_name + extension)
|
|
1686
|
+
|
|
1687
|
+
def _find_editable_value(self, payload: Any, key: str) -> str:
|
|
1688
|
+
if isinstance(payload, str):
|
|
1689
|
+
match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None
|
|
1690
|
+
if match:
|
|
1691
|
+
return match.group(1)
|
|
1692
|
+
try:
|
|
1693
|
+
payload = json.loads(payload)
|
|
1694
|
+
except json.JSONDecodeError:
|
|
1695
|
+
return ""
|
|
1696
|
+
if isinstance(payload, dict):
|
|
1697
|
+
value = payload.get(key)
|
|
1698
|
+
if isinstance(value, str) and value:
|
|
1699
|
+
return value
|
|
1700
|
+
return next((found for item in payload.values() if (found := self._find_editable_value(item, key))), "")
|
|
1701
|
+
if isinstance(payload, list):
|
|
1702
|
+
return next((found for item in payload if (found := self._find_editable_value(item, key))), "")
|
|
1703
|
+
return ""
|
|
1704
|
+
|
|
1705
|
+
def _extract_editable_file_ids(self, text: str) -> list[str]:
|
|
1706
|
+
values: list[str] = []
|
|
1707
|
+
for item in EDITABLE_ASSET_POINTER_RE.findall(text):
|
|
1708
|
+
if item not in values:
|
|
1709
|
+
values.append(item)
|
|
1710
|
+
for item in FILE_ID_RE.findall(text):
|
|
1711
|
+
if item not in values:
|
|
1712
|
+
values.append(item)
|
|
1713
|
+
return values
|
|
1714
|
+
|
|
1715
|
+
@staticmethod
|
|
1716
|
+
def _match_editable_file_id(value: str) -> str:
|
|
1717
|
+
match = FILE_ID_RE.search(value)
|
|
1718
|
+
return match.group(1) if match else ""
|
|
1719
|
+
|
|
1720
|
+
@staticmethod
|
|
1721
|
+
def _clean_editable_mime_type(value: Any) -> str:
|
|
1722
|
+
text = str(value or "").strip().lower()
|
|
1723
|
+
return text.split(";", 1)[0] if "/" in text else ""
|
|
1724
|
+
|
|
1725
|
+
def _looks_like_editable_primary(
|
|
1726
|
+
self,
|
|
1727
|
+
artifact: EditableFileArtifact,
|
|
1728
|
+
primary_suffixes: tuple[str, ...],
|
|
1729
|
+
primary_mime_types: set[str],
|
|
1730
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1731
|
+
) -> bool:
|
|
1732
|
+
path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type
|
|
1733
|
+
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)
|
|
1734
|
+
|
|
1735
|
+
@staticmethod
|
|
1736
|
+
def _looks_like_editable_zip(artifact: EditableFileArtifact) -> bool:
|
|
1737
|
+
path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type
|
|
1738
|
+
return name.endswith(".zip") or path.endswith(".zip") or mime in EDITABLE_ZIP_MIME_TYPES or mime.endswith("/zip")
|
|
1739
|
+
|
|
1740
|
+
@staticmethod
|
|
1741
|
+
def _editable_extension_from_mime_type(
|
|
1742
|
+
mime_type: str,
|
|
1743
|
+
primary_mime_types: set[str],
|
|
1744
|
+
primary_mime_keywords: tuple[str, ...],
|
|
1745
|
+
primary_default_extension: str,
|
|
1746
|
+
) -> str:
|
|
1747
|
+
if mime_type in primary_mime_types or any(keyword in mime_type for keyword in primary_mime_keywords):
|
|
1748
|
+
return primary_default_extension
|
|
1749
|
+
if mime_type in EDITABLE_ZIP_MIME_TYPES or mime_type.endswith("/zip"):
|
|
1750
|
+
return ".zip"
|
|
1751
|
+
return mimetypes.guess_extension(mime_type) or ""
|
|
1752
|
+
|
|
1753
|
+
@staticmethod
|
|
1754
|
+
def _editable_filename_from_content_disposition(content_disposition: str) -> str:
|
|
1755
|
+
extended_match = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition, re.IGNORECASE)
|
|
1756
|
+
if extended_match:
|
|
1757
|
+
return unquote(extended_match.group(1)).strip()
|
|
1758
|
+
plain_match = re.search(r'filename="([^"]+)"', content_disposition, re.IGNORECASE)
|
|
1759
|
+
return plain_match.group(1).strip() if plain_match else ""
|
|
1760
|
+
|
|
1761
|
+
@staticmethod
|
|
1762
|
+
def _sanitize_editable_filename(value: str) -> str:
|
|
1763
|
+
return Path(str(value or "").strip()).name.replace("\x00", "").strip()
|
|
1764
|
+
|
|
1765
|
+
@staticmethod
|
|
1766
|
+
def _unique_editable_path(path: Path) -> Path:
|
|
1767
|
+
if not path.exists():
|
|
1768
|
+
return path
|
|
1769
|
+
for index in range(1, 1000):
|
|
1770
|
+
candidate = path.with_name(f"{path.stem}_{index}{path.suffix}")
|
|
1771
|
+
if not candidate.exists():
|
|
1772
|
+
return candidate
|
|
1773
|
+
raise RuntimeError(f"failed to allocate output path for {path}")
|
|
1774
|
+
|
|
1775
|
+
@staticmethod
|
|
1776
|
+
def _merge_editable_artifact(current: EditableFileArtifact | None, latest: EditableFileArtifact) -> EditableFileArtifact:
|
|
1777
|
+
if current is None:
|
|
1778
|
+
return latest
|
|
1779
|
+
return EditableFileArtifact(
|
|
1780
|
+
attachment_id=latest.attachment_id or current.attachment_id,
|
|
1781
|
+
file_id=latest.file_id or current.file_id,
|
|
1782
|
+
name=latest.name or current.name,
|
|
1783
|
+
mime_type=latest.mime_type or current.mime_type,
|
|
1784
|
+
create_time=max(current.create_time, latest.create_time),
|
|
1785
|
+
author_role=latest.author_role or current.author_role,
|
|
1786
|
+
sandbox_path=latest.sandbox_path or current.sandbox_path,
|
|
1787
|
+
message_id=latest.message_id or current.message_id,
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
@staticmethod
|
|
1791
|
+
def _editable_message_text(message: Any) -> str:
|
|
1792
|
+
if not isinstance(message, dict):
|
|
1793
|
+
return ""
|
|
1794
|
+
content = message.get("content") or {}
|
|
1795
|
+
parts: list[str] = []
|
|
1796
|
+
if isinstance(content, dict):
|
|
1797
|
+
if isinstance(content.get("text"), str):
|
|
1798
|
+
parts.append(content["text"])
|
|
1799
|
+
for part in content.get("parts") or []:
|
|
1800
|
+
if isinstance(part, str):
|
|
1801
|
+
parts.append(part)
|
|
1802
|
+
elif isinstance(part, dict):
|
|
1803
|
+
parts.extend(str(part.get(key) or "") for key in ("text", "asset_pointer", "model_set_context") if part.get(key))
|
|
1804
|
+
if isinstance(message.get("content"), str):
|
|
1805
|
+
parts.append(str(message["content"]))
|
|
1806
|
+
return "\n".join(part for part in parts if part).strip()
|
|
1807
|
+
|
|
1808
|
+
@staticmethod
|
|
1809
|
+
def _extract_editable_export_paths(payload: Any, export_file_re: re.Pattern[str]) -> list[str]:
|
|
1810
|
+
if isinstance(payload, str):
|
|
1811
|
+
text = payload
|
|
1812
|
+
else:
|
|
1813
|
+
try:
|
|
1814
|
+
text = json.dumps(payload, ensure_ascii=False)
|
|
1815
|
+
except Exception:
|
|
1816
|
+
text = str(payload)
|
|
1817
|
+
values: list[str] = []
|
|
1818
|
+
for item in export_file_re.findall(text):
|
|
1819
|
+
path = str(item or "").strip()
|
|
1820
|
+
if path and path not in values:
|
|
1821
|
+
values.append(path)
|
|
1822
|
+
return values
|
|
1823
|
+
|
|
1824
|
+
def search(self, prompt: str, model: str = SEARCH_MODEL, timeout_secs: float = SEARCH_TIMEOUT_SECS,
|
|
1825
|
+
poll_interval_secs: float = SEARCH_POLL_INTERVAL_SECS) -> Dict[str, Any]:
|
|
1826
|
+
if not self.access_token:
|
|
1827
|
+
raise RuntimeError("access_token is required for search")
|
|
1828
|
+
conduit_token = self._prepare_search_conversation(prompt, model)
|
|
1829
|
+
self._bootstrap()
|
|
1830
|
+
conversation_id = self._run_search_conversation(prompt, conduit_token, model)
|
|
1831
|
+
return self._wait_search_result(conversation_id, timeout_secs, poll_interval_secs)
|
|
1832
|
+
|
|
1833
|
+
def _prepare_search_conversation(self, prompt: str, model: str) -> str:
|
|
1834
|
+
path = "/backend-api/f/conversation/prepare"
|
|
1835
|
+
response = self.session.post(
|
|
1836
|
+
self.base_url + path,
|
|
1837
|
+
headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}),
|
|
1838
|
+
json={
|
|
1839
|
+
"action": "next",
|
|
1840
|
+
"fork_from_shared_post": False,
|
|
1841
|
+
"parent_message_id": "client-created-root",
|
|
1842
|
+
"model": model,
|
|
1843
|
+
"client_prepare_state": "success",
|
|
1844
|
+
"timezone_offset_min": -480,
|
|
1845
|
+
"timezone": "Asia/Shanghai",
|
|
1846
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
1847
|
+
"system_hints": ["search"],
|
|
1848
|
+
"partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}},
|
|
1849
|
+
"supports_buffering": True,
|
|
1850
|
+
"supported_encodings": ["v1"],
|
|
1851
|
+
"client_contextual_info": {"app_name": "chatgpt.com"},
|
|
1852
|
+
},
|
|
1853
|
+
timeout=60,
|
|
1854
|
+
)
|
|
1855
|
+
ensure_ok(response, path)
|
|
1856
|
+
token = str(response.json().get("conduit_token") or "")
|
|
1857
|
+
if not token:
|
|
1858
|
+
raise RuntimeError("missing conduit_token")
|
|
1859
|
+
return token
|
|
1860
|
+
|
|
1861
|
+
def _run_search_conversation(self, prompt: str, conduit_token: str, model: str) -> str:
|
|
1862
|
+
requirements = self._get_chat_requirements()
|
|
1863
|
+
path = "/backend-api/f/conversation"
|
|
1864
|
+
response = self.session.post(
|
|
1865
|
+
self.base_url + path,
|
|
1866
|
+
headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
|
|
1867
|
+
json={
|
|
1868
|
+
"action": "next",
|
|
1869
|
+
"messages": [{
|
|
1870
|
+
"id": new_uuid(),
|
|
1871
|
+
"author": {"role": "user"},
|
|
1872
|
+
"create_time": time.time(),
|
|
1873
|
+
"content": {"content_type": "text", "parts": [prompt]},
|
|
1874
|
+
"metadata": {
|
|
1875
|
+
"developer_mode_connector_ids": [],
|
|
1876
|
+
"selected_github_repos": [],
|
|
1877
|
+
"selected_all_github_repos": False,
|
|
1878
|
+
"system_hints": ["search"],
|
|
1879
|
+
"serialization_metadata": {"custom_symbol_offsets": []},
|
|
1880
|
+
},
|
|
1881
|
+
}],
|
|
1882
|
+
"parent_message_id": "client-created-root",
|
|
1883
|
+
"model": model,
|
|
1884
|
+
"client_prepare_state": "success",
|
|
1885
|
+
"timezone_offset_min": -480,
|
|
1886
|
+
"timezone": "Asia/Shanghai",
|
|
1887
|
+
"conversation_mode": {"kind": "primary_assistant"},
|
|
1888
|
+
"enable_message_followups": True,
|
|
1889
|
+
"system_hints": [],
|
|
1890
|
+
"supports_buffering": True,
|
|
1891
|
+
"supported_encodings": ["v1"],
|
|
1892
|
+
"force_use_search": True,
|
|
1893
|
+
"client_reported_search_source": "conversation_composer_web_icon",
|
|
1894
|
+
"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"},
|
|
1895
|
+
"paragen_cot_summary_display_override": "allow",
|
|
1896
|
+
"force_parallel_switch": "auto",
|
|
1897
|
+
},
|
|
1898
|
+
timeout=300,
|
|
1899
|
+
stream=True,
|
|
1900
|
+
)
|
|
1901
|
+
ensure_ok(response, path)
|
|
1902
|
+
conversation_id = ""
|
|
1903
|
+
try:
|
|
1904
|
+
for payload in iter_sse_payloads(response):
|
|
1905
|
+
conversation_id = conversation_id or self._find_search_value(payload, "conversation_id")
|
|
1906
|
+
if payload == "[DONE]":
|
|
1907
|
+
break
|
|
1908
|
+
finally:
|
|
1909
|
+
response.close()
|
|
1910
|
+
if not conversation_id:
|
|
1911
|
+
raise RuntimeError("conversation_id not found in stream")
|
|
1912
|
+
return conversation_id
|
|
1913
|
+
|
|
1914
|
+
def _wait_search_result(self, conversation_id: str, timeout_secs: float, poll_interval_secs: float) -> Dict[str, Any]:
|
|
1915
|
+
deadline = time.time() + timeout_secs
|
|
1916
|
+
last_result: Dict[str, Any] | None = None
|
|
1917
|
+
last_answer = ""
|
|
1918
|
+
stable_hits = 0
|
|
1919
|
+
while time.time() < deadline:
|
|
1920
|
+
try:
|
|
1921
|
+
last_result = self._extract_search_result(conversation_id, self._get_search_conversation(conversation_id))
|
|
1922
|
+
except UpstreamHTTPError as exc:
|
|
1923
|
+
if exc.status_code not in {404, 409, 423, 429, 500, 502, 503, 504}:
|
|
1924
|
+
raise
|
|
1925
|
+
if last_result and last_result.get("answer"):
|
|
1926
|
+
if last_result.get("status") in SEARCH_DONE_STATUS:
|
|
1927
|
+
return last_result
|
|
1928
|
+
answer = str(last_result.get("answer") or "")
|
|
1929
|
+
stable_hits = stable_hits + 1 if answer == last_answer else 0
|
|
1930
|
+
last_answer = answer
|
|
1931
|
+
if stable_hits >= 2:
|
|
1932
|
+
return last_result
|
|
1933
|
+
time.sleep(poll_interval_secs)
|
|
1934
|
+
if last_result:
|
|
1935
|
+
return last_result
|
|
1936
|
+
raise RuntimeError(f"timed out waiting for search result: {conversation_id}")
|
|
1937
|
+
|
|
1938
|
+
def _get_search_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
|
1939
|
+
path = f"/backend-api/conversation/{conversation_id}"
|
|
1940
|
+
headers = self._headers(path, {"Accept": "*/*"})
|
|
1941
|
+
headers["Referer"] = f"{self.base_url}/c/{conversation_id}"
|
|
1942
|
+
headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}"
|
|
1943
|
+
response = self.session.get(self.base_url + path, headers=headers, timeout=60)
|
|
1944
|
+
ensure_ok(response, path)
|
|
1945
|
+
return response.json()
|
|
1946
|
+
|
|
1947
|
+
def _extract_search_result(self, conversation_id: str, conversation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1948
|
+
messages = []
|
|
1949
|
+
for node in (conversation.get("mapping") or {}).values():
|
|
1950
|
+
message = (node or {}).get("message") or {}
|
|
1951
|
+
if ((message.get("author") or {}).get("role") or "") == "assistant":
|
|
1952
|
+
messages.append(message)
|
|
1953
|
+
message = max(messages, key=lambda item: float(item.get("create_time") or 0.0)) if messages else {}
|
|
1954
|
+
metadata = message.get("metadata") if isinstance(message.get("metadata"), dict) else {}
|
|
1955
|
+
finish_details = metadata.get("finish_details") if isinstance(metadata.get("finish_details"), dict) else {}
|
|
1956
|
+
answer = self._search_message_text(message)
|
|
1957
|
+
sources = self._extract_search_sources(message)
|
|
1958
|
+
for url in SEARCH_URL_RE.findall(answer):
|
|
1959
|
+
url = self._clean_search_url(url)
|
|
1960
|
+
if url and all(item["url"] != url for item in sources):
|
|
1961
|
+
sources.append({"title": "", "url": url, "snippet": "", "source_type": ""})
|
|
1962
|
+
return {
|
|
1963
|
+
"conversation_id": conversation_id,
|
|
1964
|
+
"status": str(finish_details.get("type") or metadata.get("status") or self._find_search_value(message, "status") or "").strip(),
|
|
1965
|
+
"answer": answer,
|
|
1966
|
+
"sources": sources,
|
|
1967
|
+
"assistant_message_id": str(message.get("id") or ""),
|
|
1968
|
+
"create_time": float(message.get("create_time") or 0.0),
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
def _extract_search_sources(self, payload: Any) -> list[Dict[str, str]]:
|
|
1972
|
+
sources: list[Dict[str, str]] = []
|
|
1973
|
+
for obj in self._walk_search_dicts(payload):
|
|
1974
|
+
metadata = obj.get("metadata") if isinstance(obj.get("metadata"), dict) else {}
|
|
1975
|
+
url = self._clean_search_url(obj.get("url") or obj.get("link") or obj.get("source_url") or metadata.get("url"))
|
|
1976
|
+
if url and all(item["url"] != url for item in sources):
|
|
1977
|
+
sources.append({
|
|
1978
|
+
"title": str(obj.get("title") or obj.get("name") or obj.get("source") or "").strip(),
|
|
1979
|
+
"url": url,
|
|
1980
|
+
"snippet": str(obj.get("snippet") or obj.get("text") or obj.get("description") or "").strip(),
|
|
1981
|
+
"source_type": str(obj.get("type") or obj.get("source_type") or "").strip(),
|
|
1982
|
+
})
|
|
1983
|
+
return sources
|
|
1984
|
+
|
|
1985
|
+
def _search_message_text(self, message: Any) -> str:
|
|
1986
|
+
content = message.get("content") if isinstance(message, dict) else {}
|
|
1987
|
+
parts = []
|
|
1988
|
+
if isinstance(content, dict):
|
|
1989
|
+
if isinstance(content.get("text"), str):
|
|
1990
|
+
parts.append(content["text"])
|
|
1991
|
+
for part in content.get("parts") or []:
|
|
1992
|
+
if isinstance(part, str):
|
|
1993
|
+
parts.append(part)
|
|
1994
|
+
elif isinstance(part, dict):
|
|
1995
|
+
parts.extend(str(part.get(key) or "") for key in ("text", "summary", "content") if part.get(key))
|
|
1996
|
+
elif isinstance(content, str):
|
|
1997
|
+
parts.append(content)
|
|
1998
|
+
return "\n".join(part.strip() for part in parts if str(part).strip()).strip()
|
|
1999
|
+
|
|
2000
|
+
def _find_search_value(self, payload: Any, key: str) -> str:
|
|
2001
|
+
if isinstance(payload, str):
|
|
2002
|
+
match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None
|
|
2003
|
+
if match:
|
|
2004
|
+
return match.group(1)
|
|
2005
|
+
try:
|
|
2006
|
+
payload = json.loads(payload)
|
|
2007
|
+
except json.JSONDecodeError:
|
|
2008
|
+
return ""
|
|
2009
|
+
if isinstance(payload, dict):
|
|
2010
|
+
value = payload.get(key)
|
|
2011
|
+
if isinstance(value, str) and value:
|
|
2012
|
+
return value
|
|
2013
|
+
return next((found for item in payload.values() if (found := self._find_search_value(item, key))), "")
|
|
2014
|
+
if isinstance(payload, list):
|
|
2015
|
+
return next((found for item in payload if (found := self._find_search_value(item, key))), "")
|
|
2016
|
+
return ""
|
|
2017
|
+
|
|
2018
|
+
def _walk_search_dicts(self, payload: Any) -> list[Dict[str, Any]]:
|
|
2019
|
+
if isinstance(payload, dict):
|
|
2020
|
+
return [payload, *(item for value in payload.values() for item in self._walk_search_dicts(value))]
|
|
2021
|
+
if isinstance(payload, list):
|
|
2022
|
+
return [item for value in payload for item in self._walk_search_dicts(value)]
|
|
2023
|
+
return []
|
|
2024
|
+
|
|
2025
|
+
def _clean_search_url(self, value: Any) -> str:
|
|
2026
|
+
return str(value or "").strip().rstrip(".,;,。;")
|
|
2027
|
+
|
|
2028
|
+
@staticmethod
|
|
2029
|
+
def _add_unique(values: list[str], candidates: list[str]) -> None:
|
|
2030
|
+
for candidate in candidates:
|
|
2031
|
+
if candidate and candidate not in values:
|
|
2032
|
+
values.append(candidate)
|
|
2033
|
+
|
|
2034
|
+
@classmethod
|
|
2035
|
+
def _extract_image_reference_ids(cls, payload: Any) -> tuple[list[str], list[str]]:
|
|
2036
|
+
file_ids: list[str] = []
|
|
2037
|
+
sediment_ids: list[str] = []
|
|
2038
|
+
|
|
2039
|
+
def walk(value: Any) -> None:
|
|
2040
|
+
if isinstance(value, str):
|
|
2041
|
+
# 只提取真正的图片文件 ID(file_00000000... 格式)和 file-service:// URI
|
|
2042
|
+
cls._add_unique(file_ids, FILE_SERVICE_ID_RE.findall(value))
|
|
2043
|
+
cls._add_unique(file_ids, REAL_IMAGE_FILE_ID_RE.findall(value))
|
|
2044
|
+
cls._add_unique(sediment_ids, SEDIMENT_ID_RE.findall(value))
|
|
2045
|
+
return
|
|
2046
|
+
if isinstance(value, dict):
|
|
2047
|
+
for item in value.values():
|
|
2048
|
+
walk(item)
|
|
2049
|
+
return
|
|
2050
|
+
if isinstance(value, list):
|
|
2051
|
+
for item in value:
|
|
2052
|
+
walk(item)
|
|
2053
|
+
|
|
2054
|
+
walk(payload)
|
|
2055
|
+
return file_ids, sediment_ids
|
|
2056
|
+
|
|
2057
|
+
@classmethod
|
|
2058
|
+
def _has_image_asset_pointer(cls, payload: Any) -> bool:
|
|
2059
|
+
if isinstance(payload, dict):
|
|
2060
|
+
if str(payload.get("content_type") or "") == "image_asset_pointer":
|
|
2061
|
+
return True
|
|
2062
|
+
asset_pointer = str(payload.get("asset_pointer") or "")
|
|
2063
|
+
if asset_pointer.startswith(("file-service://", "sediment://")):
|
|
2064
|
+
return True
|
|
2065
|
+
return any(cls._has_image_asset_pointer(item) for item in payload.values())
|
|
2066
|
+
if isinstance(payload, list):
|
|
2067
|
+
return any(cls._has_image_asset_pointer(item) for item in payload)
|
|
2068
|
+
return False
|
|
2069
|
+
|
|
2070
|
+
def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
|
|
2071
|
+
"""从 conversation 明细里提取图片工具输出记录。"""
|
|
2072
|
+
mapping = data.get("mapping") or {}
|
|
2073
|
+
records = []
|
|
2074
|
+
for message_id, node in mapping.items():
|
|
2075
|
+
message = (node or {}).get("message") or {}
|
|
2076
|
+
author = message.get("author") or {}
|
|
2077
|
+
metadata = message.get("metadata") or {}
|
|
2078
|
+
content = message.get("content") or {}
|
|
2079
|
+
role = str(author.get("role") or "").strip().lower()
|
|
2080
|
+
if role not in {"tool", "assistant"}:
|
|
2081
|
+
continue
|
|
2082
|
+
is_image_gen = metadata.get("async_task_type") == "image_gen"
|
|
2083
|
+
has_asset_pointer = self._has_image_asset_pointer(content) or self._has_image_asset_pointer(metadata)
|
|
2084
|
+
if role == "assistant" and not (is_image_gen or has_asset_pointer):
|
|
2085
|
+
continue
|
|
2086
|
+
file_ids, sediment_ids = self._extract_image_reference_ids({"content": content, "metadata": metadata})
|
|
2087
|
+
if not is_image_gen and not has_asset_pointer and not file_ids and not sediment_ids:
|
|
2088
|
+
continue
|
|
2089
|
+
records.append(
|
|
2090
|
+
{"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
|
|
2091
|
+
"sediment_ids": sediment_ids})
|
|
2092
|
+
return sorted(records, key=lambda item: item["create_time"])
|
|
2093
|
+
|
|
2094
|
+
@staticmethod
|
|
2095
|
+
def _find_content_policy_error_in_conversation(data: Dict[str, Any]) -> str:
|
|
2096
|
+
"""从对话文档中查找内容政策违规错误消息。
|
|
2097
|
+
|
|
2098
|
+
上游拒绝生成图片时,错误消息会出现在 assistant 消息的文本中。
|
|
2099
|
+
本方法遍历所有 assistant/tool 消息,检查是否包含内容政策违规关键词,
|
|
2100
|
+
如果匹配则返回该消息文本(截断至 500 字符),否则返回空字符串。
|
|
2101
|
+
"""
|
|
2102
|
+
mapping = data.get("mapping") or {}
|
|
2103
|
+
for node in mapping.values():
|
|
2104
|
+
message = (node or {}).get("message") or {}
|
|
2105
|
+
author = message.get("author") or {}
|
|
2106
|
+
role = str(author.get("role") or "").strip().lower()
|
|
2107
|
+
if role not in {"assistant", "tool"}:
|
|
2108
|
+
continue
|
|
2109
|
+
content = message.get("content") or {}
|
|
2110
|
+
# 提取消息文本
|
|
2111
|
+
text_parts: list[str] = []
|
|
2112
|
+
if isinstance(content, dict):
|
|
2113
|
+
msg_parts = content.get("parts") or []
|
|
2114
|
+
if isinstance(msg_parts, list):
|
|
2115
|
+
for part in msg_parts:
|
|
2116
|
+
if isinstance(part, str) and part.strip():
|
|
2117
|
+
text_parts.append(part.strip())
|
|
2118
|
+
text_field = str(content.get("text") or "")
|
|
2119
|
+
if text_field.strip():
|
|
2120
|
+
text_parts.append(text_field.strip())
|
|
2121
|
+
elif isinstance(content, str) and content.strip():
|
|
2122
|
+
text_parts.append(content.strip())
|
|
2123
|
+
msg_text = "\n".join(text_parts)
|
|
2124
|
+
if msg_text and _is_content_policy_error(msg_text):
|
|
2125
|
+
return msg_text[:500]
|
|
2126
|
+
return ""
|
|
2127
|
+
|
|
2128
|
+
def _poll_image_results(
|
|
2129
|
+
self,
|
|
2130
|
+
conversation_id: str,
|
|
2131
|
+
timeout_secs: float = 120.0,
|
|
2132
|
+
initial_file_ids: list[str] | None = None,
|
|
2133
|
+
initial_sediment_ids: list[str] | None = None,
|
|
2134
|
+
) -> tuple[list[str], list[str]]:
|
|
2135
|
+
"""Poll the conversation document until image file ids appear or budget runs out.
|
|
2136
|
+
|
|
2137
|
+
- Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
|
|
2138
|
+
image generation takes ~30s; polling immediately wastes requests and trips
|
|
2139
|
+
a transient 429 the upstream returns within ~200ms of the SSE stream
|
|
2140
|
+
closing (the conversation document is not yet committed).
|
|
2141
|
+
- Subsequent polls are image_poll_interval_secs apart (default 10s).
|
|
2142
|
+
- On upstream 429 / 5xx or network errors, backs off exponentially
|
|
2143
|
+
(capped at 16s, +jitter) honoring Retry-After when present.
|
|
2144
|
+
- All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
|
|
2145
|
+
"""
|
|
2146
|
+
start = time.time()
|
|
2147
|
+
attempt = 0
|
|
2148
|
+
interval = float(config.image_poll_interval_secs)
|
|
2149
|
+
initial_wait = float(config.image_poll_initial_wait_secs)
|
|
2150
|
+
file_ids: list[str] = []
|
|
2151
|
+
sediment_ids: list[str] = []
|
|
2152
|
+
self._add_unique(file_ids, initial_file_ids or [])
|
|
2153
|
+
self._add_unique(sediment_ids, initial_sediment_ids or [])
|
|
2154
|
+
has_initial_ids = bool(file_ids or sediment_ids)
|
|
2155
|
+
last_hit_key: tuple[tuple[str, ...], tuple[str, ...]] | None = (
|
|
2156
|
+
(tuple(file_ids), tuple(sediment_ids)) if has_initial_ids else None
|
|
2157
|
+
)
|
|
2158
|
+
logger.info({
|
|
2159
|
+
"event": "image_poll_start",
|
|
2160
|
+
"conversation_id": conversation_id,
|
|
2161
|
+
"timeout_secs": timeout_secs,
|
|
2162
|
+
"initial_wait_secs": initial_wait,
|
|
2163
|
+
"interval_secs": interval,
|
|
2164
|
+
"initial_file_ids": file_ids,
|
|
2165
|
+
"initial_sediment_ids": sediment_ids,
|
|
2166
|
+
})
|
|
2167
|
+
|
|
2168
|
+
def _remaining() -> float:
|
|
2169
|
+
return timeout_secs - (time.time() - start)
|
|
2170
|
+
|
|
2171
|
+
if has_initial_ids and config.image_settle_enabled:
|
|
2172
|
+
settle_for = min(config.image_settle_secs, max(0.0, _remaining()))
|
|
2173
|
+
if settle_for > 0:
|
|
2174
|
+
time.sleep(settle_for)
|
|
2175
|
+
elif initial_wait > 0:
|
|
2176
|
+
jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
|
|
2177
|
+
sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
|
|
2178
|
+
if sleep_for > 0:
|
|
2179
|
+
time.sleep(sleep_for)
|
|
2180
|
+
|
|
2181
|
+
def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
|
|
2182
|
+
# retry_after=0 means "retry immediately" — must not be coerced via falsy check.
|
|
2183
|
+
base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
|
|
2184
|
+
backoff = base + random.uniform(0, 0.5)
|
|
2185
|
+
remaining = _remaining()
|
|
2186
|
+
if remaining <= 0:
|
|
2187
|
+
return False
|
|
2188
|
+
sleep_for = min(backoff, remaining)
|
|
2189
|
+
log_payload: Dict[str, Any] = {
|
|
2190
|
+
"event": "image_poll_retry",
|
|
2191
|
+
"conversation_id": conversation_id,
|
|
2192
|
+
"attempt": attempt,
|
|
2193
|
+
"reason": reason,
|
|
2194
|
+
"sleep_secs": round(sleep_for, 2),
|
|
2195
|
+
}
|
|
2196
|
+
if status_code is not None:
|
|
2197
|
+
log_payload["status_code"] = status_code
|
|
2198
|
+
if error is not None:
|
|
2199
|
+
log_payload["error"] = error
|
|
2200
|
+
logger.warning(log_payload)
|
|
2201
|
+
time.sleep(sleep_for)
|
|
2202
|
+
return True
|
|
2203
|
+
|
|
2204
|
+
last_task_error = ""
|
|
2205
|
+
while _remaining() > 0:
|
|
2206
|
+
attempt += 1
|
|
2207
|
+
# 在每次轮询时,检查 /backend-api/tasks/ 是否有错误(仅记录,不中断)
|
|
2208
|
+
# 内容政策违规检测通过对话文本进行(在 _find_content_policy_error_in_conversation 中)
|
|
2209
|
+
last_task_error = ""
|
|
2210
|
+
try:
|
|
2211
|
+
tasks = self._query_backend_tasks(conversation_id=conversation_id, timeout_secs=5.0)
|
|
2212
|
+
for task in tasks:
|
|
2213
|
+
is_error, error_msg, metadata = self.check_task_error(task)
|
|
2214
|
+
if is_error and error_msg:
|
|
2215
|
+
last_task_error = error_msg
|
|
2216
|
+
logger.info({
|
|
2217
|
+
"event": "image_poll_task_error_not_blocking",
|
|
2218
|
+
"conversation_id": conversation_id,
|
|
2219
|
+
"attempt": attempt,
|
|
2220
|
+
"error_msg": error_msg,
|
|
2221
|
+
"metadata": metadata,
|
|
2222
|
+
})
|
|
2223
|
+
except Exception as exc:
|
|
2224
|
+
# tasks 查询失败不影响正常轮询流程
|
|
2225
|
+
logger.debug({
|
|
2226
|
+
"event": "image_poll_task_check_failed",
|
|
2227
|
+
"conversation_id": conversation_id,
|
|
2228
|
+
"attempt": attempt,
|
|
2229
|
+
"error": str(exc),
|
|
2230
|
+
})
|
|
2231
|
+
|
|
2232
|
+
try:
|
|
2233
|
+
conversation = self._get_conversation(conversation_id)
|
|
2234
|
+
except UpstreamHTTPError as exc:
|
|
2235
|
+
if exc.status_code in (429, 500, 502, 503, 504):
|
|
2236
|
+
if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
|
|
2237
|
+
continue
|
|
2238
|
+
break
|
|
2239
|
+
raise
|
|
2240
|
+
except requests.exceptions.RequestException as exc:
|
|
2241
|
+
if _retry_sleep("network", None, str(exc), None):
|
|
2242
|
+
continue
|
|
2243
|
+
break
|
|
2244
|
+
|
|
2245
|
+
for record in self._extract_image_tool_records(conversation):
|
|
2246
|
+
for file_id in record["file_ids"]:
|
|
2247
|
+
if file_id not in file_ids:
|
|
2248
|
+
file_ids.append(file_id)
|
|
2249
|
+
for sediment_id in record["sediment_ids"]:
|
|
2250
|
+
if sediment_id not in sediment_ids:
|
|
2251
|
+
sediment_ids.append(sediment_id)
|
|
2252
|
+
|
|
2253
|
+
# 检查对话文本中是否包含内容政策违规错误
|
|
2254
|
+
# 当上游拒绝生成图片时,错误消息会出现在对话文档的 assistant 消息中,
|
|
2255
|
+
# 而非 /backend-api/tasks/ 的 task error 结构中。
|
|
2256
|
+
# 如果在没有找到图片文件 ID 的同时检测到内容政策违规,立即中断轮询。
|
|
2257
|
+
if not file_ids and not sediment_ids:
|
|
2258
|
+
policy_msg = self._find_content_policy_error_in_conversation(conversation)
|
|
2259
|
+
if policy_msg:
|
|
2260
|
+
logger.warning({
|
|
2261
|
+
"event": "image_poll_conversation_text_policy_violation",
|
|
2262
|
+
"conversation_id": conversation_id,
|
|
2263
|
+
"attempt": attempt,
|
|
2264
|
+
"error_msg": policy_msg[:200],
|
|
2265
|
+
})
|
|
2266
|
+
raise ImageContentPolicyError(policy_msg, conversation_id or "")
|
|
2267
|
+
|
|
2268
|
+
logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
|
|
2269
|
+
"file_ids": file_ids, "sediment_ids": sediment_ids})
|
|
2270
|
+
if file_ids or sediment_ids:
|
|
2271
|
+
if not config.image_check_before_hit_enabled:
|
|
2272
|
+
# 先check再hit 机制关闭:直接返回首次发现的 file_ids
|
|
2273
|
+
logger.info({"event": "image_poll_hit_no_settle", "conversation_id": conversation_id,
|
|
2274
|
+
"file_ids": file_ids, "sediment_ids": sediment_ids})
|
|
2275
|
+
return file_ids, sediment_ids
|
|
2276
|
+
hit_key = (tuple(file_ids), tuple(sediment_ids))
|
|
2277
|
+
if last_hit_key == hit_key:
|
|
2278
|
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
|
|
2279
|
+
"sediment_ids": sediment_ids})
|
|
2280
|
+
return file_ids, sediment_ids
|
|
2281
|
+
last_hit_key = hit_key
|
|
2282
|
+
if not config.image_settle_enabled:
|
|
2283
|
+
# 二次确认机制关闭:直接返回首次发现的 file_ids
|
|
2284
|
+
logger.info({"event": "image_poll_hit_settle_disabled", "conversation_id": conversation_id,
|
|
2285
|
+
"file_ids": file_ids, "sediment_ids": sediment_ids})
|
|
2286
|
+
return file_ids, sediment_ids
|
|
2287
|
+
logger.info({"event": "image_poll_hit_pending_settle", "conversation_id": conversation_id,
|
|
2288
|
+
"file_ids": file_ids, "sediment_ids": sediment_ids,
|
|
2289
|
+
"settle_secs": config.image_settle_secs})
|
|
2290
|
+
wait = min(config.image_settle_secs, max(0.0, _remaining()))
|
|
2291
|
+
if wait > 0:
|
|
2292
|
+
time.sleep(wait)
|
|
2293
|
+
continue
|
|
2294
|
+
return file_ids, sediment_ids
|
|
2295
|
+
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
|
|
2296
|
+
"elapsed_secs": round(time.time() - start, 1)})
|
|
2297
|
+
wait = min(interval, max(0.0, _remaining()))
|
|
2298
|
+
if wait > 0:
|
|
2299
|
+
time.sleep(wait)
|
|
2300
|
+
logger.info({
|
|
2301
|
+
"event": "image_poll_timeout",
|
|
2302
|
+
"conversation_id": conversation_id,
|
|
2303
|
+
"timeout_secs": timeout_secs,
|
|
2304
|
+
"attempts_made": attempt,
|
|
2305
|
+
# attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
|
|
2306
|
+
"initial_wait_exhausted_budget": attempt == 0,
|
|
2307
|
+
"last_task_error": last_task_error if last_task_error else None,
|
|
2308
|
+
})
|
|
2309
|
+
exc = ImagePollTimeoutError(
|
|
2310
|
+
f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
|
|
2311
|
+
f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
|
|
2312
|
+
f"也可能是账号被限流或生图队列拥堵导致。",
|
|
2313
|
+
conversation_id or "",
|
|
2314
|
+
)
|
|
2315
|
+
if last_task_error:
|
|
2316
|
+
setattr(exc, "task_error", last_task_error)
|
|
2317
|
+
raise exc
|
|
2318
|
+
|
|
2319
|
+
def _get_file_download_url(self, file_id: str) -> str:
|
|
2320
|
+
"""获取文件下载地址。"""
|
|
2321
|
+
path = f"/backend-api/files/{file_id}/download"
|
|
2322
|
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
|
2323
|
+
timeout=60)
|
|
2324
|
+
ensure_ok(response, path)
|
|
2325
|
+
data = response.json()
|
|
2326
|
+
return data.get("download_url") or data.get("url") or ""
|
|
2327
|
+
|
|
2328
|
+
def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
|
|
2329
|
+
"""通过 conversation 附件接口获取下载地址。"""
|
|
2330
|
+
path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
|
|
2331
|
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
|
2332
|
+
timeout=60)
|
|
2333
|
+
ensure_ok(response, path)
|
|
2334
|
+
data = response.json()
|
|
2335
|
+
return data.get("download_url") or data.get("url") or ""
|
|
2336
|
+
|
|
2337
|
+
def _query_backend_tasks(
|
|
2338
|
+
self,
|
|
2339
|
+
conversation_id: str = "",
|
|
2340
|
+
task_id: str = "",
|
|
2341
|
+
timeout_secs: float = 30.0,
|
|
2342
|
+
) -> list[Dict[str, Any]]:
|
|
2343
|
+
"""查询 /backend-api/tasks/ 接口获取异步任务状态和错误信息。
|
|
2344
|
+
|
|
2345
|
+
参数:
|
|
2346
|
+
- `conversation_id`:可选。按 conversation_id 过滤任务。
|
|
2347
|
+
- `task_id`:可选。按 task_id 过滤任务。
|
|
2348
|
+
- `timeout_secs`:请求超时秒数。
|
|
2349
|
+
|
|
2350
|
+
返回:
|
|
2351
|
+
- 任务列表,每个任务包含 image_gen_message 等字段。
|
|
2352
|
+
"""
|
|
2353
|
+
path = "/backend-api/tasks"
|
|
2354
|
+
response = self.session.get(
|
|
2355
|
+
self.base_url + path,
|
|
2356
|
+
headers=self._headers(path, {"Accept": "application/json"}),
|
|
2357
|
+
timeout=timeout_secs,
|
|
2358
|
+
)
|
|
2359
|
+
ensure_ok(response, path)
|
|
2360
|
+
data = response.json()
|
|
2361
|
+
tasks = data.get("tasks", [])
|
|
2362
|
+
if not isinstance(tasks, list):
|
|
2363
|
+
return []
|
|
2364
|
+
|
|
2365
|
+
# 按 conversation_id 或 task_id 过滤
|
|
2366
|
+
if conversation_id:
|
|
2367
|
+
tasks = [
|
|
2368
|
+
t for t in tasks
|
|
2369
|
+
if isinstance(t, dict) and (
|
|
2370
|
+
t.get("conversation_id") == conversation_id
|
|
2371
|
+
or t.get("original_conversation_id") == conversation_id
|
|
2372
|
+
)
|
|
2373
|
+
]
|
|
2374
|
+
if task_id:
|
|
2375
|
+
tasks = [t for t in tasks if isinstance(t, dict) and t.get("task_id") == task_id]
|
|
2376
|
+
return tasks
|
|
2377
|
+
|
|
2378
|
+
def check_task_error(self, task: Dict[str, Any]) -> tuple[bool, str, Dict[str, Any]]:
|
|
2379
|
+
"""检查单个任务是否包含结构化错误。
|
|
2380
|
+
|
|
2381
|
+
通过以下字段判断(不依赖文本匹配):
|
|
2382
|
+
- image_gen_message.metadata.is_error == True
|
|
2383
|
+
- image_gen_message.author.role == "assistant" (而非 "tool")
|
|
2384
|
+
- image_gen_message.content.content_type == "text" (而非 "multimodal_text")
|
|
2385
|
+
|
|
2386
|
+
返回:
|
|
2387
|
+
- (is_error, error_msg, metadata)
|
|
2388
|
+
"""
|
|
2389
|
+
img_msg = task.get("image_gen_message") or {}
|
|
2390
|
+
if not img_msg:
|
|
2391
|
+
return False, "", {}
|
|
2392
|
+
|
|
2393
|
+
metadata = img_msg.get("metadata") or {}
|
|
2394
|
+
content = img_msg.get("content") or {}
|
|
2395
|
+
author = img_msg.get("author") or {}
|
|
2396
|
+
|
|
2397
|
+
is_error = metadata.get("is_error", False)
|
|
2398
|
+
is_text_only = content.get("content_type") == "text"
|
|
2399
|
+
is_assistant_role = author.get("role") == "assistant"
|
|
2400
|
+
|
|
2401
|
+
# 提取错误文本
|
|
2402
|
+
error_msg = ""
|
|
2403
|
+
if is_error and is_text_only:
|
|
2404
|
+
parts = content.get("parts", [])
|
|
2405
|
+
error_msg = "".join(p for p in parts if isinstance(p, str))
|
|
2406
|
+
|
|
2407
|
+
return is_error, error_msg, metadata
|
|
2408
|
+
|
|
2409
|
+
def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
|
|
2410
|
+
"""把图片结果 id 解析成可下载 URL。"""
|
|
2411
|
+
urls = []
|
|
2412
|
+
skip_patterns = {"file_upload"}
|
|
2413
|
+
for file_id in file_ids:
|
|
2414
|
+
if file_id in skip_patterns:
|
|
2415
|
+
logger.debug({
|
|
2416
|
+
"event": "image_file_id_skipped",
|
|
2417
|
+
"source": "file",
|
|
2418
|
+
"conversation_id": conversation_id,
|
|
2419
|
+
"id": file_id,
|
|
2420
|
+
})
|
|
2421
|
+
continue
|
|
2422
|
+
try:
|
|
2423
|
+
url = self._get_file_download_url(file_id)
|
|
2424
|
+
except Exception as exc:
|
|
2425
|
+
logger.debug({
|
|
2426
|
+
"event": "image_download_url_failed",
|
|
2427
|
+
"source": "file",
|
|
2428
|
+
"conversation_id": conversation_id,
|
|
2429
|
+
"id": file_id,
|
|
2430
|
+
"error": repr(exc),
|
|
2431
|
+
})
|
|
2432
|
+
continue
|
|
2433
|
+
if url:
|
|
2434
|
+
if url not in urls:
|
|
2435
|
+
urls.append(url)
|
|
2436
|
+
else:
|
|
2437
|
+
logger.debug({
|
|
2438
|
+
"event": "image_download_url_empty",
|
|
2439
|
+
"source": "file",
|
|
2440
|
+
"conversation_id": conversation_id,
|
|
2441
|
+
"id": file_id,
|
|
2442
|
+
})
|
|
2443
|
+
if not conversation_id or not sediment_ids:
|
|
2444
|
+
logger.debug({
|
|
2445
|
+
"event": "image_urls_resolved",
|
|
2446
|
+
"conversation_id": conversation_id,
|
|
2447
|
+
"file_ids": file_ids,
|
|
2448
|
+
"sediment_ids": sediment_ids,
|
|
2449
|
+
"urls": urls,
|
|
2450
|
+
})
|
|
2451
|
+
return urls
|
|
2452
|
+
for sediment_id in sediment_ids:
|
|
2453
|
+
try:
|
|
2454
|
+
url = self._get_attachment_download_url(conversation_id, sediment_id)
|
|
2455
|
+
except Exception as exc:
|
|
2456
|
+
logger.debug({
|
|
2457
|
+
"event": "image_download_url_failed",
|
|
2458
|
+
"source": "sediment",
|
|
2459
|
+
"conversation_id": conversation_id,
|
|
2460
|
+
"id": sediment_id,
|
|
2461
|
+
"error": repr(exc),
|
|
2462
|
+
})
|
|
2463
|
+
continue
|
|
2464
|
+
if url:
|
|
2465
|
+
if url not in urls:
|
|
2466
|
+
urls.append(url)
|
|
2467
|
+
else:
|
|
2468
|
+
logger.debug({
|
|
2469
|
+
"event": "image_download_url_empty",
|
|
2470
|
+
"source": "sediment",
|
|
2471
|
+
"conversation_id": conversation_id,
|
|
2472
|
+
"id": sediment_id,
|
|
2473
|
+
})
|
|
2474
|
+
logger.debug({
|
|
2475
|
+
"event": "image_urls_resolved",
|
|
2476
|
+
"conversation_id": conversation_id,
|
|
2477
|
+
"file_ids": file_ids,
|
|
2478
|
+
"sediment_ids": sediment_ids,
|
|
2479
|
+
"urls": urls,
|
|
2480
|
+
})
|
|
2481
|
+
return urls
|
|
2482
|
+
|
|
2483
|
+
def resolve_conversation_image_urls(
|
|
2484
|
+
self,
|
|
2485
|
+
conversation_id: str,
|
|
2486
|
+
file_ids: list[str],
|
|
2487
|
+
sediment_ids: list[str],
|
|
2488
|
+
poll: bool = True,
|
|
2489
|
+
poll_timeout_secs: float | None = None,
|
|
2490
|
+
) -> list[str]:
|
|
2491
|
+
file_ids = [item for item in file_ids if item != "file_upload"]
|
|
2492
|
+
sediment_ids = list(sediment_ids)
|
|
2493
|
+
timeout = poll_timeout_secs if poll_timeout_secs is not None else config.image_poll_timeout_secs
|
|
2494
|
+
# 当 check-before-hit 和 settle 均已关闭,且 SSE 已给出 file_ids 时,
|
|
2495
|
+
# 跳过轮询直接解析 URL,省去 initial_wait + 轮询耗时。
|
|
2496
|
+
if poll and conversation_id and (file_ids or sediment_ids):
|
|
2497
|
+
if not config.image_check_before_hit_enabled and not config.image_settle_enabled:
|
|
2498
|
+
logger.info({
|
|
2499
|
+
"event": "image_resolve_skip_poll_direct_resolve",
|
|
2500
|
+
"conversation_id": conversation_id,
|
|
2501
|
+
"file_ids": file_ids,
|
|
2502
|
+
"sediment_ids": sediment_ids,
|
|
2503
|
+
})
|
|
2504
|
+
return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
|
2505
|
+
if poll and conversation_id:
|
|
2506
|
+
logger.info({
|
|
2507
|
+
"event": "image_resolve_poll_needed",
|
|
2508
|
+
"conversation_id": conversation_id,
|
|
2509
|
+
"initial_file_ids": file_ids,
|
|
2510
|
+
"initial_sediment_ids": sediment_ids,
|
|
2511
|
+
"poll_timeout_secs": timeout,
|
|
2512
|
+
})
|
|
2513
|
+
try:
|
|
2514
|
+
polled_file_ids, polled_sediment_ids = self._poll_image_results(
|
|
2515
|
+
conversation_id,
|
|
2516
|
+
timeout,
|
|
2517
|
+
file_ids,
|
|
2518
|
+
sediment_ids,
|
|
2519
|
+
)
|
|
2520
|
+
except ImagePollTimeoutError as exc:
|
|
2521
|
+
# 如果轮询超时且有 task error(如 moderation 拦截),抛出 ImageContentPolicyError
|
|
2522
|
+
# 而非 ImagePollTimeoutError,让调用方能区分真正的超时和上游拒绝
|
|
2523
|
+
task_error = getattr(exc, "task_error", "")
|
|
2524
|
+
if not file_ids and not sediment_ids:
|
|
2525
|
+
if task_error:
|
|
2526
|
+
raise ImageContentPolicyError(task_error, conversation_id or "") from exc
|
|
2527
|
+
raise
|
|
2528
|
+
logger.warning({
|
|
2529
|
+
"event": "image_resolve_poll_partial_timeout",
|
|
2530
|
+
"conversation_id": conversation_id,
|
|
2531
|
+
"file_ids": file_ids,
|
|
2532
|
+
"sediment_ids": sediment_ids,
|
|
2533
|
+
})
|
|
2534
|
+
except Exception as exc:
|
|
2535
|
+
if not file_ids and not sediment_ids:
|
|
2536
|
+
raise
|
|
2537
|
+
logger.warning({
|
|
2538
|
+
"event": "image_resolve_poll_partial_error",
|
|
2539
|
+
"conversation_id": conversation_id,
|
|
2540
|
+
"file_ids": file_ids,
|
|
2541
|
+
"sediment_ids": sediment_ids,
|
|
2542
|
+
"error": repr(exc),
|
|
2543
|
+
})
|
|
2544
|
+
else:
|
|
2545
|
+
file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
|
|
2546
|
+
sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
|
|
2547
|
+
return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
|
2548
|
+
|
|
2549
|
+
def download_image_bytes(self, urls: list[str]) -> list[bytes]:
|
|
2550
|
+
images = []
|
|
2551
|
+
for url in urls:
|
|
2552
|
+
response = self.session.get(url, timeout=120)
|
|
2553
|
+
ensure_ok(response, "image_download")
|
|
2554
|
+
if response.content not in images:
|
|
2555
|
+
images.append(response.content)
|
|
2556
|
+
return images
|
|
2557
|
+
|
|
2558
|
+
def stream_conversation(
|
|
2559
|
+
self,
|
|
2560
|
+
messages: Optional[list[Dict[str, Any]]] = None,
|
|
2561
|
+
model: str = "auto",
|
|
2562
|
+
prompt: str = "",
|
|
2563
|
+
images: Optional[list[str]] = None,
|
|
2564
|
+
system_hints: Optional[list[str]] = None,
|
|
2565
|
+
thinking_effort: str = "",
|
|
2566
|
+
) -> Iterator[str]:
|
|
2567
|
+
system_hints = system_hints or []
|
|
2568
|
+
if "picture_v2" in system_hints:
|
|
2569
|
+
yield from self._stream_picture_conversation(prompt, model, images or [])
|
|
2570
|
+
return
|
|
2571
|
+
|
|
2572
|
+
normalized = messages or [{"role": "user", "content": prompt}]
|
|
2573
|
+
self._bootstrap()
|
|
2574
|
+
requirements = self._get_chat_requirements()
|
|
2575
|
+
path, timezone = self._chat_target()
|
|
2576
|
+
payload = self._conversation_payload(normalized, model, timezone, thinking_effort=thinking_effort)
|
|
2577
|
+
response = self.session.post(
|
|
2578
|
+
self.base_url + path,
|
|
2579
|
+
headers=self._conversation_headers(path, requirements),
|
|
2580
|
+
json=payload,
|
|
2581
|
+
timeout=300,
|
|
2582
|
+
stream=True,
|
|
2583
|
+
)
|
|
2584
|
+
ensure_ok(response, path)
|
|
2585
|
+
try:
|
|
2586
|
+
yield from iter_sse_payloads(response)
|
|
2587
|
+
finally:
|
|
2588
|
+
response.close()
|
|
2589
|
+
|
|
2590
|
+
def _report_progress(self, step: str) -> None:
|
|
2591
|
+
"""Report progress step to the callback if set."""
|
|
2592
|
+
if self.progress_callback:
|
|
2593
|
+
try:
|
|
2594
|
+
self.progress_callback(step)
|
|
2595
|
+
except Exception:
|
|
2596
|
+
pass
|
|
2597
|
+
|
|
2598
|
+
def _stream_picture_conversation(
|
|
2599
|
+
self,
|
|
2600
|
+
prompt: str,
|
|
2601
|
+
model: str,
|
|
2602
|
+
images: list[str],
|
|
2603
|
+
) -> Iterator[str]:
|
|
2604
|
+
if not self.access_token:
|
|
2605
|
+
raise RuntimeError("access_token is required for image endpoints")
|
|
2606
|
+
self._report_progress("uploading")
|
|
2607
|
+
references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
|
|
2608
|
+
self._report_progress("bootstrapping")
|
|
2609
|
+
self._bootstrap()
|
|
2610
|
+
self._report_progress("getting_token")
|
|
2611
|
+
requirements = self._get_chat_requirements()
|
|
2612
|
+
self._report_progress("preparing_conversation")
|
|
2613
|
+
conduit_token = self._prepare_image_conversation(prompt, requirements, model)
|
|
2614
|
+
self._report_progress("starting_generation")
|
|
2615
|
+
response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
|
|
2616
|
+
self._report_progress("generating")
|
|
2617
|
+
yield from self._iter_sse_payloads_capped(response, float(config.image_poll_timeout_secs))
|
|
2618
|
+
|
|
2619
|
+
def _iter_sse_payloads_capped(self, response: Any, hard_cap_secs: float) -> Iterator[str]:
|
|
2620
|
+
"""按墙钟硬上限消费图片 SSE 流,避免上游异常时长连接被无限挂起。
|
|
2621
|
+
|
|
2622
|
+
curl_cffi 在 stream=True + 标量 timeout 下不限制流式 body 的总读取时长,
|
|
2623
|
+
上游未生成图片却保持连接时,读取会一直阻塞直到边缘重置(曾观测到单条流
|
|
2624
|
+
挂起约 29.5 分钟才失败)。这里复用「图片轮询超时」作为硬上限:到点后关闭
|
|
2625
|
+
底层连接以解除阻塞,并抛出明确错误,让任务快速失败而非长时间挂起。
|
|
2626
|
+
"""
|
|
2627
|
+
deadline = time.monotonic() + hard_cap_secs
|
|
2628
|
+
# 看门狗:SSE 读取可能阻塞在底层 curl 调用中,超时后关闭连接以强制解除阻塞
|
|
2629
|
+
watchdog = threading.Timer(hard_cap_secs, response.close)
|
|
2630
|
+
watchdog.daemon = True
|
|
2631
|
+
watchdog.start()
|
|
2632
|
+
timeout_message = f"图片生成流已超过硬上限 {int(hard_cap_secs)} 秒,已强制中断(上游可能未生成图片)"
|
|
2633
|
+
try:
|
|
2634
|
+
for payload in iter_sse_payloads(response):
|
|
2635
|
+
yield payload
|
|
2636
|
+
if time.monotonic() >= deadline:
|
|
2637
|
+
raise ImageStreamHardTimeoutError(timeout_message)
|
|
2638
|
+
except ImageStreamHardTimeoutError:
|
|
2639
|
+
raise
|
|
2640
|
+
except Exception as exc:
|
|
2641
|
+
# 看门狗关闭连接后,底层读取会抛出 curl 错误,这里统一转成明确的硬上限错误
|
|
2642
|
+
if time.monotonic() >= deadline:
|
|
2643
|
+
raise ImageStreamHardTimeoutError(timeout_message) from exc
|
|
2644
|
+
raise
|
|
2645
|
+
finally:
|
|
2646
|
+
watchdog.cancel()
|
|
2647
|
+
try:
|
|
2648
|
+
response.close()
|
|
2649
|
+
except Exception:
|
|
2650
|
+
pass
|
|
2651
|
+
|
|
2652
|
+
def _bootstrap(self) -> None:
|
|
2653
|
+
"""预热首页,并提取 PoW 相关脚本引用。"""
|
|
2654
|
+
response = self.session.get(
|
|
2655
|
+
self.base_url + "/",
|
|
2656
|
+
headers=self._bootstrap_headers(),
|
|
2657
|
+
timeout=30,
|
|
2658
|
+
)
|
|
2659
|
+
ensure_ok(response, "bootstrap")
|
|
2660
|
+
self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
|
|
2661
|
+
if not self.pow_script_sources:
|
|
2662
|
+
self.pow_script_sources = [DEFAULT_POW_SCRIPT]
|
|
2663
|
+
|
|
2664
|
+
def _get_chat_requirements(self) -> ChatRequirements:
|
|
2665
|
+
"""获取当前模式对话所需的 sentinel token(prepare + finalize 两步流程)。"""
|
|
2666
|
+
base = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
|
|
2667
|
+
p_token = build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)
|
|
2668
|
+
|
|
2669
|
+
prepare_path = base + "/prepare"
|
|
2670
|
+
response = self.session.post(
|
|
2671
|
+
self.base_url + prepare_path,
|
|
2672
|
+
headers=self._headers(prepare_path, {"Content-Type": "application/json"}),
|
|
2673
|
+
json={"p": p_token},
|
|
2674
|
+
timeout=30,
|
|
2675
|
+
)
|
|
2676
|
+
ensure_ok(response, "chat_requirements_prepare")
|
|
2677
|
+
prepare_data = response.json()
|
|
2678
|
+
|
|
2679
|
+
if (prepare_data.get("arkose") or {}).get("required"):
|
|
2680
|
+
raise RuntimeError("chat requirements requires arkose token, which is not implemented")
|
|
2681
|
+
|
|
2682
|
+
proof_token = ""
|
|
2683
|
+
proof_info = prepare_data.get("proofofwork") or {}
|
|
2684
|
+
if proof_info.get("required"):
|
|
2685
|
+
proof_token = build_proof_token(
|
|
2686
|
+
proof_info.get("seed", ""),
|
|
2687
|
+
proof_info.get("difficulty", ""),
|
|
2688
|
+
self.user_agent,
|
|
2689
|
+
script_sources=self.pow_script_sources,
|
|
2690
|
+
data_build=self.pow_data_build,
|
|
2691
|
+
)
|
|
2692
|
+
|
|
2693
|
+
turnstile_token = ""
|
|
2694
|
+
turnstile_info = prepare_data.get("turnstile") or {}
|
|
2695
|
+
if turnstile_info.get("required") and turnstile_info.get("dx"):
|
|
2696
|
+
turnstile_token = solve_turnstile_token(turnstile_info["dx"], p_token) or ""
|
|
2697
|
+
|
|
2698
|
+
finalize_path = base + "/finalize"
|
|
2699
|
+
response = self.session.post(
|
|
2700
|
+
self.base_url + finalize_path,
|
|
2701
|
+
headers=self._headers(finalize_path, {"Content-Type": "application/json"}),
|
|
2702
|
+
json={
|
|
2703
|
+
"prepare_token": prepare_data.get("prepare_token", ""),
|
|
2704
|
+
"proof_token": proof_token,
|
|
2705
|
+
"turnstile_token": turnstile_token,
|
|
2706
|
+
},
|
|
2707
|
+
timeout=30,
|
|
2708
|
+
)
|
|
2709
|
+
ensure_ok(response, "chat_requirements_finalize")
|
|
2710
|
+
data = response.json()
|
|
2711
|
+
|
|
2712
|
+
token = data.get("token", "")
|
|
2713
|
+
if not token:
|
|
2714
|
+
message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
|
|
2715
|
+
raise RuntimeError(f"{message}: {data}")
|
|
2716
|
+
|
|
2717
|
+
return ChatRequirements(
|
|
2718
|
+
token=token,
|
|
2719
|
+
proof_token=proof_token,
|
|
2720
|
+
turnstile_token=turnstile_token,
|
|
2721
|
+
so_token=data.get("so_token", ""),
|
|
2722
|
+
raw_finalize=data,
|
|
2723
|
+
)
|
|
2724
|
+
|
|
2725
|
+
def _chat_target(self) -> tuple[str, str]:
|
|
2726
|
+
if self.access_token:
|
|
2727
|
+
return "/backend-api/conversation", "Asia/Shanghai"
|
|
2728
|
+
return "/backend-anon/conversation", "America/Los_Angeles"
|
|
2729
|
+
|
|
2730
|
+
def list_models(self) -> Dict[str, Any]:
|
|
2731
|
+
"""返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
|
|
2732
|
+
self._bootstrap()
|
|
2733
|
+
path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
|
|
2734
|
+
"/backend-anon/models?iim=false&is_gizmo=false"
|
|
2735
|
+
)
|
|
2736
|
+
route = "/backend-api/models" if self.access_token else "/backend-anon/models"
|
|
2737
|
+
context = "auth_models" if self.access_token else "anon_models"
|
|
2738
|
+
response = self.session.get(
|
|
2739
|
+
self.base_url + path,
|
|
2740
|
+
headers=self._headers(route),
|
|
2741
|
+
timeout=30,
|
|
2742
|
+
)
|
|
2743
|
+
ensure_ok(response, context)
|
|
2744
|
+
data = []
|
|
2745
|
+
seen = set()
|
|
2746
|
+
for item in response.json().get("models", []):
|
|
2747
|
+
if not isinstance(item, dict):
|
|
2748
|
+
continue
|
|
2749
|
+
slug = str(item.get("slug", "")).strip()
|
|
2750
|
+
if not slug or slug in seen:
|
|
2751
|
+
continue
|
|
2752
|
+
seen.add(slug)
|
|
2753
|
+
data.append({
|
|
2754
|
+
"id": slug,
|
|
2755
|
+
"object": "model",
|
|
2756
|
+
"created": int(item.get("created") or 0),
|
|
2757
|
+
"owned_by": str(item.get("owned_by") or "chatgpt"),
|
|
2758
|
+
"permission": [],
|
|
2759
|
+
"root": slug,
|
|
2760
|
+
"parent": None,
|
|
2761
|
+
})
|
|
2762
|
+
data.sort(key=lambda item: item["id"])
|
|
2763
|
+
return {"object": "list", "data": data}
|