Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
View File
+476
View File
@@ -0,0 +1,476 @@
import base64
import hashlib
import json
import mimetypes
import re
import time
import uuid
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any, Iterator
from urllib.parse import urlparse
from curl_cffi import requests
from fastapi import HTTPException
from services.proxy_service import proxy_settings
from utils.log import logger
BASE_IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"}
IMAGE_MODEL_PLAN_TYPES = ("plus", "team", "pro")
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
PREFIXED_CODEX_IMAGE_MODELS = {
f"{plan_type}-{CODEX_IMAGE_MODEL}"
for plan_type in IMAGE_MODEL_PLAN_TYPES
}
IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
PUBLIC_IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
OUTPUT_DIR = Path(__file__).resolve().parent / "output"
SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
MAX_JSON_IMAGE_BYTES = 10 * 1024 * 1024
MAX_JSON_EDIT_IMAGES = 10
DATA_URL_IMAGE_RE = re.compile(r"^data:(?P<mime>[-+./\w]+);base64,(?P<data>.*)$", re.DOTALL)
REMOTE_IMAGE_TIMEOUT_SECONDS = 20
def _image_extension(mime_type: str) -> str:
image_type = mime_type.split("/", 1)[1].split(";", 1)[0].lower() if "/" in mime_type else "png"
return "jpg" if image_type == "jpeg" else image_type or "png"
def _decode_json_image_string(value: str, index: int, filename: str | None = None, mime_type: str | None = None) -> tuple[bytes, str, str]:
text = value.strip()
if not text:
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
match = DATA_URL_IMAGE_RE.match(text)
if match:
resolved_mime = (match.group("mime") or "image/png").lower()
encoded = match.group("data")
else:
if text.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail={"error": "remote image URLs are not supported"})
resolved_mime = (mime_type or "image/png").lower()
encoded = text
if resolved_mime == "image/jpg":
resolved_mime = "image/jpeg"
if resolved_mime not in SUPPORTED_JSON_IMAGE_MIME_TYPES:
raise HTTPException(status_code=400, detail={"error": "unsupported image mime type"})
try:
image_data = base64.b64decode(encoded, validate=True)
except Exception as exc:
raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
if not image_data:
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
if len(image_data) > MAX_JSON_IMAGE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image file is too large"})
return image_data, filename or f"image_{index}.{_image_extension(resolved_mime)}", resolved_mime
def _extract_json_image_value(item: object) -> tuple[str, str | None, str | None]:
if isinstance(item, str):
return item, None, None
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail={"error": "image entry must be a base64 string or object"})
filename = str(item.get("filename") or item.get("file_name") or "").strip() or None
mime_type = str(item.get("mime_type") or item.get("mimeType") or "").strip() or None
value = item.get("b64_json") or item.get("base64")
if not value:
image_url = item.get("image_url") or item.get("url")
if isinstance(image_url, dict):
filename = filename or str(image_url.get("filename") or image_url.get("file_name") or "").strip() or None
mime_type = mime_type or str(image_url.get("mime_type") or image_url.get("mimeType") or "").strip() or None
value = image_url.get("url") or image_url.get("image_url")
else:
value = image_url
if not isinstance(value, str) or not value.strip():
raise HTTPException(status_code=400, detail={"error": "image entry must include image data"})
return value, filename, mime_type
def normalize_json_edit_images(image: object = None, images: object = None) -> list[tuple[bytes, str, str]]:
raw_images = images if images is not None else image
if raw_images is None:
raise HTTPException(status_code=400, detail={"error": "image file is required"})
entries = raw_images if isinstance(raw_images, list) else [raw_images]
if not entries:
raise HTTPException(status_code=400, detail={"error": "image file is required"})
if len(entries) > MAX_JSON_EDIT_IMAGES:
raise HTTPException(status_code=400, detail={"error": f"images supports up to {MAX_JSON_EDIT_IMAGES} items"})
normalized = []
for index, item in enumerate(entries, start=1):
value, filename, mime_type = _extract_json_image_value(item)
normalized.append(_decode_json_image_string(value, index, filename, mime_type))
return normalized
def new_uuid() -> str:
return str(uuid.uuid4())
def split_image_model(model: object) -> tuple[str | None, str | None]:
normalized = str(model or "").strip().lower()
if not normalized:
return None, None
if normalized in BASE_IMAGE_MODELS:
return None, normalized
for plan_type in IMAGE_MODEL_PLAN_TYPES:
prefix = f"{plan_type}-"
if normalized.startswith(prefix):
base_model = normalized[len(prefix):]
if base_model == CODEX_IMAGE_MODEL:
return plan_type, base_model
return None, None
def is_supported_image_model(model: object) -> bool:
_, base_model = split_image_model(model)
return base_model is not None
def is_codex_image_model(model: object) -> bool:
_, base_model = split_image_model(model)
return base_model == CODEX_IMAGE_MODEL
def is_image_chat_request(body: dict[str, object]) -> bool:
model = str(body.get("model") or "").strip()
modalities = body.get("modalities")
if is_supported_image_model(model):
return True
return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
_UPSTREAM_BODY_LOG_LIMIT = 500
class UpstreamHTTPError(RuntimeError):
"""Raised when an upstream HTTP call returns a non-2xx status.
Carries structured fields (status_code, body, retry_after) so callers can
branch on status code instead of string-matching on str(exc). The full
body is preserved on the instance; the formatted message truncates it
to keep log lines reasonable.
"""
def __init__(
self,
context: str,
status_code: int,
body: Any,
retry_after: int | None = None,
) -> None:
self.context = context
self.status_code = status_code
self.body = body
self.retry_after = retry_after
if isinstance(body, (dict, list)):
try:
body_str = json.dumps(body, ensure_ascii=False)
except (TypeError, ValueError):
body_str = repr(body)
else:
body_str = str(body)
if len(body_str) > _UPSTREAM_BODY_LOG_LIMIT:
body_str = body_str[:_UPSTREAM_BODY_LOG_LIMIT] + "…[truncated]"
super().__init__(f"{context} failed: status={status_code}, body={body_str}")
def ensure_ok(response: requests.Response, context: str) -> None:
if 200 <= response.status_code < 300:
return
body: Any = response.text
try:
body = response.json()
except Exception:
pass
retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
retry_after: int | None = None
if retry_after_header is not None:
ra_str = str(retry_after_header).strip()
if ra_str.isdigit():
retry_after = int(ra_str)
else:
try:
retry_after = max(0, int((parsedate_to_datetime(ra_str) - datetime.now(timezone.utc)).total_seconds()))
except (TypeError, ValueError, OverflowError):
pass
raise UpstreamHTTPError(context, response.status_code, body, retry_after=retry_after)
def sse_json_stream(items) -> Iterator[str]:
yield ": stream-open\n\n"
try:
for item in items:
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.warning({
"event": "sse_stream_error",
"error_type": exc.__class__.__name__,
"error": str(exc),
})
error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else {
"error": {"message": str(exc), "type": exc.__class__.__name__}
}
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
def anthropic_sse_stream(items) -> Iterator[str]:
try:
for item in items:
event = str(item.get("type") or "message_delta") if isinstance(item, dict) else "message_delta"
yield f"event: {event}\n"
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.warning({
"event": "anthropic_sse_stream_error",
"error_type": exc.__class__.__name__,
"error": str(exc),
})
error = {"type": "error", "error": {"type": exc.__class__.__name__, "message": str(exc)}}
yield "event: error\n"
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
def iter_sse_payloads(response: requests.Response) -> Iterator[str]:
for raw_line in response.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload:
yield payload
def save_images_from_text(text: str, prefix: str) -> list[Path]:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
saved_paths: list[Path] = []
timestamp = int(time.time() * 1000)
for index, data_url in enumerate(matches, start=1):
header, encoded = data_url.split(",", 1)
image_type = header.split(";")[0].removeprefix("data:image/").strip() or "png"
extension = "jpg" if image_type == "jpeg" else image_type
output_path = OUTPUT_DIR / f"{prefix}_{timestamp}_{index}.{extension}"
output_path.write_bytes(base64.b64decode(encoded))
saved_paths.append(output_path)
return saved_paths
def anonymize_token(token: object) -> str:
value = str(token or "").strip()
if not value:
return "token:empty"
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:10]
return f"token:{digest}"
def extract_response_prompt(input_value: object) -> str:
if isinstance(input_value, str):
return input_value.strip()
if isinstance(input_value, dict):
role = str(input_value.get("role") or "").strip().lower()
if role and role != "user":
return ""
return extract_prompt_from_message_content(input_value.get("content"))
if not isinstance(input_value, list):
return ""
prompt_parts: list[str] = []
for item in input_value:
if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text":
text = str(item.get("text") or "").strip()
if text:
prompt_parts.append(text)
continue
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role and role != "user":
continue
prompt = extract_prompt_from_message_content(item.get("content"))
if prompt:
prompt_parts.append(prompt)
return "\n".join(prompt_parts).strip()
def has_response_image_generation_tool(body: dict[str, object]) -> bool:
tools = body.get("tools")
if isinstance(tools, list):
for tool in tools:
if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
return True
tool_choice = body.get("tool_choice")
return isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation"
def extract_prompt_from_message_content(content: object) -> str:
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
parts: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
item_type = str(item.get("type") or "").strip()
if item_type == "text":
text = str(item.get("text") or "").strip()
if text:
parts.append(text)
elif item_type == "input_text":
text = str(item.get("text") or item.get("input_text") or "").strip()
if text:
parts.append(text)
return "\n".join(parts).strip()
def _message_image_url(value: object) -> str:
if isinstance(value, dict):
return str(value.get("url") or value.get("image_url") or "").strip()
return str(value or "").strip()
def _decode_message_image_url(value: object) -> tuple[bytes, str] | None:
source = _message_image_url(value)
if source.startswith("data:"):
header, _, data = source.partition(",")
mime = header.split(";")[0].removeprefix("data:") or "image/png"
return base64.b64decode(data), mime
if not source.startswith(("http://", "https://")):
return None
parsed = urlparse(source)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
try:
response = requests.get(
source,
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api vision fetcher"},
timeout=REMOTE_IMAGE_TIMEOUT_SECONDS,
allow_redirects=True,
**proxy_settings.build_session_kwargs(),
)
except Exception as exc:
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
if not 200 <= response.status_code < 300:
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
content_length = str(response.headers.get("content-length") or "").strip()
if content_length.isdigit() and int(content_length) > MAX_JSON_IMAGE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
image_data = response.content
if not image_data:
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
if len(image_data) > MAX_JSON_IMAGE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
mime = str(response.headers.get("content-type") or "image/png").split(";", 1)[0].lower()
guessed_mime = mimetypes.guess_type(parsed.path)[0] or ""
if mime and not mime.startswith("image/") and mime not in {"application/octet-stream", "binary/octet-stream"}:
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
if not mime.startswith("image/") and guessed_mime.startswith("image/"):
mime = guessed_mime
if not mime.startswith("image/"):
mime = "image/png"
return image_data, mime
def _decode_message_image_object(item: dict[str, object]) -> tuple[bytes, str] | None:
data = item.get("data")
if isinstance(data, (bytes, bytearray)):
return bytes(data), str(item.get("mime") or item.get("mime_type") or "image/png")
for key in ("image_url", "url"):
image = _decode_message_image_url(item.get(key))
if image:
return image
value = item.get("b64_json") or item.get("base64")
if isinstance(value, str) and value.strip():
image_data, _, mime = _decode_json_image_string(
value,
1,
mime_type=str(item.get("mime") or item.get("mime_type") or item.get("mimeType") or "image/png"),
)
return image_data, mime
source = item.get("source")
if isinstance(source, dict) and str(source.get("type") or "") == "base64":
encoded = str(source.get("data") or "")
mime = str(source.get("media_type") or source.get("mime_type") or "image/png")
image_data, _, resolved_mime = _decode_json_image_string(encoded, 1, mime_type=mime)
return image_data, resolved_mime
return None
def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]:
if not isinstance(content, list):
return []
images = []
for item in content:
if not isinstance(item, dict):
continue
item_type = str(item.get("type") or "").strip()
if item_type == "image_url":
image = _decode_message_image_url(item.get("image_url") or item.get("url") or item)
if image:
images.append(image)
elif item_type in {"input_image", "image"}:
image = _decode_message_image_object(item)
if image:
images.append(image)
return images
def extract_chat_image(body: dict[str, object]) -> list[tuple[bytes, str]]:
messages = body.get("messages")
if not isinstance(messages, list):
return []
for message in reversed(messages):
if not isinstance(message, dict):
continue
if str(message.get("role") or "").strip().lower() != "user":
continue
images = extract_image_from_message_content(message.get("content"))
if images:
return images
return []
def extract_chat_prompt(body: dict[str, object]) -> str:
direct_prompt = str(body.get("prompt") or "").strip()
if direct_prompt:
return direct_prompt
messages = body.get("messages")
if not isinstance(messages, list):
return ""
prompt_parts: list[str] = []
for message in messages:
if not isinstance(message, dict):
continue
if str(message.get("role") or "").strip().lower() != "user":
continue
prompt = extract_prompt_from_message_content(message.get("content"))
if prompt:
prompt_parts.append(prompt)
return "\n".join(prompt_parts).strip()
def parse_image_count(raw_value: object) -> int:
try:
value = int(raw_value or 1)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
if value < 1 or value > 4:
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
return value
def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
markdown_images: list[str] = []
for index, item in enumerate(image_items, start=1):
if not isinstance(item, dict):
continue
b64_json = str(item.get("b64_json") or "").strip()
if b64_json:
markdown_images.append(f"![image_{index}](data:image/png;base64,{b64_json})")
return "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
+346
View File
@@ -0,0 +1,346 @@
from __future__ import annotations
import base64
import math
import re
from io import BytesIO
from typing import Any
from PIL import Image
DEFAULT_IMAGE_SIZE = (1024, 1024)
IMAGE_INPUT_TOKEN_MODEL = "gpt-5.4-mini"
PATCH_SIZE = 32
TILE_SIZE = 512
TILE_HIGH_SHORT_SIDE = 768
PATCH_1536_MODELS = (
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5-codex-mini",
"gpt-5.1-codex-mini",
"gpt-5.2-codex",
"gpt-5.2-chat-latest",
"o4-mini",
"gpt-4.1-mini",
"gpt-4.1-nano",
)
PATCH_MULTIPLIERS = {
"gpt-5.4-mini": 1.62,
"gpt-5.4-nano": 2.46,
"gpt-5-mini": 1.62,
"gpt-5-nano": 2.46,
"gpt-4.1-mini": 1.62,
"gpt-4.1-nano": 2.46,
"o4-mini": 1.72,
}
def _model_name(model: str) -> str:
return str(model or "").strip().lower()
def image_size_from_bytes(data: bytes) -> tuple[int, int] | None:
if not data:
return None
try:
with Image.open(BytesIO(data)) as image:
width, height = image.size
except Exception:
return None
if width <= 0 or height <= 0:
return None
return int(width), int(height)
def _decode_data_url(value: str) -> bytes:
text = str(value or "").strip()
payload = text.split(",", 1)[1] if text.startswith("data:") and "," in text else text
return base64.b64decode(payload)
def image_size_from_data_url(value: str) -> tuple[int, int] | None:
try:
return image_size_from_bytes(_decode_data_url(value))
except Exception:
return None
def parse_image_size(size: object, default: tuple[int, int] = DEFAULT_IMAGE_SIZE) -> tuple[int, int]:
if isinstance(size, (tuple, list)) and len(size) >= 2:
try:
width = int(size[0])
height = int(size[1])
if width > 0 and height > 0:
return width, height
except (TypeError, ValueError):
pass
match = re.search(r"(\d{2,5})\D+(\d{2,5})", str(size or ""))
if not match:
return default
width, height = int(match.group(1)), int(match.group(2))
return (width, height) if width > 0 and height > 0 else default
def _patch_count(width: float, height: float) -> int:
return math.ceil(width / PATCH_SIZE) * math.ceil(height / PATCH_SIZE)
def _patch_multiplier(model: str) -> float:
name = _model_name(model)
for prefix, multiplier in PATCH_MULTIPLIERS.items():
if name.startswith(prefix):
return multiplier
return 1.0
def _patch_limits(model: str, detail: str) -> tuple[int, int] | None:
name = _model_name(model)
if any(name.startswith(prefix) for prefix in PATCH_1536_MODELS):
return 1536, 2048
if name.startswith("gpt-5.5"):
return (10000, 6000) if detail in {"auto", "original"} else (2500, 2048)
if name.startswith("gpt-5.4"):
return (10000, 6000) if detail == "original" else (2500, 2048)
return None
def _patch_tokens(width: int, height: int, model: str, detail: str) -> int:
multiplier = _patch_multiplier(model)
if detail == "low":
return math.ceil(256 * multiplier)
limits = _patch_limits(model, detail)
if limits is None:
return 0
patch_budget, max_dimension = limits
scale = min(1.0, max_dimension / max(width, height))
resized_width = width * scale
resized_height = height * scale
if _patch_count(resized_width, resized_height) > patch_budget:
shrink_factor = math.sqrt((PATCH_SIZE * PATCH_SIZE * patch_budget) / (resized_width * resized_height))
width_units = resized_width * shrink_factor / PATCH_SIZE
height_units = resized_height * shrink_factor / PATCH_SIZE
adjusted_shrink_factor = shrink_factor * min(
math.floor(width_units) / width_units if width_units else 1,
math.floor(height_units) / height_units if height_units else 1,
)
resized_width *= adjusted_shrink_factor
resized_height *= adjusted_shrink_factor
tokens = min(_patch_count(max(1, resized_width), max(1, resized_height)), patch_budget)
return math.ceil(tokens * multiplier)
def _tile_rates(model: str) -> tuple[int, int]:
name = _model_name(model)
if name in {"gpt-5", "gpt-5-chat-latest"}:
return 70, 140
if name.startswith("gpt-4o-mini"):
return 2833, 5667
if name.startswith(("o1", "o1-pro", "o3")):
return 75, 150
if name.startswith("computer-use-preview"):
return 65, 129
return 85, 170
def _tile_tokens(width: int, height: int, model: str, detail: str) -> int:
base_tokens, tile_tokens = _tile_rates(model)
if detail == "low":
return base_tokens
scale = min(1.0, 2048 / max(width, height))
resized_width = width * scale
resized_height = height * scale
short_side = min(resized_width, resized_height)
if short_side > 0:
scale = TILE_HIGH_SHORT_SIDE / short_side
resized_width *= scale
resized_height *= scale
tiles = math.ceil(resized_width / TILE_SIZE) * math.ceil(resized_height / TILE_SIZE)
return base_tokens + tiles * tile_tokens
def count_image_input_tokens(
width: int,
height: int,
model: str,
detail: str = "auto",
input_fidelity: str = "low",
) -> int:
if width <= 0 or height <= 0:
return 0
detail = str(detail or "auto").strip().lower() or "auto"
return _patch_tokens(width, height, IMAGE_INPUT_TOKEN_MODEL, detail)
def _part_size(part: dict[str, Any]) -> tuple[int, int] | None:
try:
width = int(part.get("width") or 0)
height = int(part.get("height") or 0)
except (TypeError, ValueError):
width = height = 0
if width > 0 and height > 0:
return width, height
data = part.get("data")
if isinstance(data, (bytes, bytearray)):
return image_size_from_bytes(bytes(data))
image_url = part.get("image_url")
if isinstance(image_url, dict):
image_url = image_url.get("url") or image_url.get("image_url")
if isinstance(image_url, str) and image_url.startswith("data:"):
return image_size_from_data_url(image_url)
source = part.get("source")
if isinstance(source, dict) and str(source.get("type") or "") == "base64":
try:
return image_size_from_bytes(base64.b64decode(str(source.get("data") or "")))
except Exception:
return None
return None
def count_image_content_tokens(content: object, model: str, default_detail: str = "auto") -> int:
if not isinstance(content, list):
return 0
total = 0
for part in content:
if not isinstance(part, dict):
continue
part_type = str(part.get("type") or "").strip()
if part_type not in {"image", "image_url", "input_image"} and not part.get("source"):
continue
size = _part_size(part)
if not size:
continue
total += count_image_input_tokens(
size[0],
size[1],
model,
str(part.get("detail") or default_detail or "auto"),
str(part.get("input_fidelity") or part.get("inputFidelity") or "low"),
)
return total
def count_image_inputs_tokens(images: object, model: str, default_detail: str = "auto") -> int:
if not images:
return 0
total = 0
entries = images if isinstance(images, list) else [images]
for image in entries:
data = image[0] if isinstance(image, tuple) and image else image
if not isinstance(data, (bytes, bytearray)):
continue
size = image_size_from_bytes(bytes(data))
if size:
total += count_image_input_tokens(size[0], size[1], model, default_detail)
return total
def count_generated_image_tokens(width: int, height: int, quality: str = "auto") -> int:
patches = _patch_count(width, height)
quality = str(quality or "auto").strip().lower()
if quality == "low":
return math.ceil(patches * 17 / 64)
if quality in {"high", "hd"}:
return math.ceil(patches * 65 / 16)
return math.ceil(patches * 33 / 32)
def count_image_output_tokens(size: object = None, quality: str = "auto", count: int = 1) -> int:
width, height = parse_image_size(size)
return max(0, int(count or 0)) * count_generated_image_tokens(width, height, quality)
def count_image_output_items_tokens(
items: object,
size: object = None,
quality: str = "auto",
) -> int:
if not isinstance(items, list) or not items:
return 0
fallback_size = parse_image_size(size)
total = 0
for item in items:
image_size = None
if isinstance(item, dict):
b64_json = str(item.get("b64_json") or "").strip()
if b64_json:
try:
image_size = image_size_from_bytes(base64.b64decode(b64_json))
except Exception:
image_size = None
width, height = image_size or fallback_size
total += count_generated_image_tokens(width, height, quality)
return total
def token_usage(
input_text_tokens: int = 0,
input_image_tokens: int = 0,
output_text_tokens: int = 0,
output_image_tokens: int = 0,
) -> dict[str, Any]:
input_tokens = max(0, int(input_text_tokens or 0)) + max(0, int(input_image_tokens or 0))
output_tokens = max(0, int(output_text_tokens or 0)) + max(0, int(output_image_tokens or 0))
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_tokens_details": {
"text_tokens": max(0, int(input_text_tokens or 0)),
"image_tokens": max(0, int(input_image_tokens or 0)),
"cached_tokens": 0,
},
"output_tokens_details": {
"text_tokens": max(0, int(output_text_tokens or 0)),
"image_tokens": max(0, int(output_image_tokens or 0)),
"reasoning_tokens": 0,
},
}
def image_usage(
input_text_tokens: int = 0,
input_image_tokens: int = 0,
output_tokens: int = 0,
) -> dict[str, Any]:
return token_usage(
input_text_tokens=input_text_tokens,
input_image_tokens=input_image_tokens,
output_image_tokens=output_tokens,
)
def chat_usage_from_image_usage(usage: dict[str, Any]) -> dict[str, Any]:
input_tokens = int(usage.get("input_tokens") or 0)
output_tokens = int(usage.get("output_tokens") or 0)
input_details = usage.get("input_tokens_details") if isinstance(usage.get("input_tokens_details"), dict) else {}
output_details = usage.get("output_tokens_details") if isinstance(usage.get("output_tokens_details"), dict) else {}
return {
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"prompt_tokens_details": {
"text_tokens": int(input_details.get("text_tokens") or 0),
"image_tokens": int(input_details.get("image_tokens") or 0),
"cached_tokens": int(input_details.get("cached_tokens") or 0),
},
"completion_tokens_details": {
"text_tokens": int(output_details.get("text_tokens") or 0),
"image_tokens": int(output_details.get("image_tokens") or 0),
"reasoning_tokens": int(output_details.get("reasoning_tokens") or 0),
},
}
+110
View File
@@ -0,0 +1,110 @@
import base64
import binascii
import json
import logging
import re
from typing import Any
class Logger:
_DATA_URL_RE = re.compile(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+")
_JSON_B64_RE = re.compile(r'("b64_json"\s*:\s*")([A-Za-z0-9+/=]+)(")')
def __init__(self, name: str = "chatgpt2api") -> None:
self._logger = logging.getLogger(name)
if not self._logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
self._logger.addHandler(handler)
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = False
def _enabled(self, level: str) -> bool:
try:
from services.config import config
levels = set(config.log_levels)
except Exception:
levels = set()
return level in (levels or {"info", "warning", "error"})
def _mask_string(self, value: str, keep: int = 10) -> str:
if len(value) <= keep:
return value
return value[:keep] + "..."
def _mask_base64(self, value: str) -> str:
if value.startswith("data:") and ";base64," in value:
header, _, data = value.partition(",")
return f"{header},{self._mask_string(data, 24)} (base64 len={len(data)})"
return f"{self._mask_string(value, 24)} (base64 len={len(value)})"
def _is_base64_string(self, value: str) -> bool:
if len(value) < 64 or len(value) % 4 != 0:
return False
if not any(char in value for char in "+/="):
return False
try:
base64.b64decode(value, validate=True)
return True
except (binascii.Error, ValueError):
return False
def _sanitize_string(self, value: str) -> str:
stripped = value.strip()
if stripped.startswith("data:") and ";base64," in stripped:
return self._mask_base64(stripped)
if self._is_base64_string(stripped):
return self._mask_base64(stripped)
sanitized = self._DATA_URL_RE.sub(lambda match: self._mask_base64(match.group(0)), value)
sanitized = self._JSON_B64_RE.sub(
lambda match: f'{match.group(1)}{self._mask_base64(match.group(2))}{match.group(3)}',
sanitized,
)
if sanitized != value:
return sanitized
return value
def _sanitize(self, value: Any) -> Any:
if isinstance(value, dict):
sanitized = {}
for key, item in value.items():
lowered_key = key.lower()
if isinstance(item, str) and ("token" in lowered_key or lowered_key == "dx"):
sanitized[key] = self._mask_string(item)
elif isinstance(item, str) and ("base64" in lowered_key or lowered_key == "b64_json"):
sanitized[key] = self._mask_base64(item)
else:
sanitized[key] = self._sanitize(item)
return sanitized
if isinstance(value, list):
return [self._sanitize(item) for item in value]
if isinstance(value, tuple):
return tuple(self._sanitize(item) for item in value)
if isinstance(value, str):
return self._sanitize_string(value)
return value
def _message(self, value: Any) -> str:
sanitized = self._sanitize(value)
if isinstance(sanitized, str):
return sanitized
return json.dumps(sanitized, ensure_ascii=False, default=str)
def debug(self, message: Any) -> None:
if self._enabled("debug"):
self._logger.debug(self._message(message))
def info(self, message: Any) -> None:
if self._enabled("info"):
self._logger.info(self._message(message))
def warning(self, message: Any) -> None:
if self._enabled("warning"):
self._logger.warning(self._message(message))
def error(self, message: Any) -> None:
if self._enabled("error"):
self._logger.error(self._message(message))
logger = Logger()
+19
View File
@@ -0,0 +1,19 @@
"""PKCE (Proof Key for Code Exchange) 工具函数"""
from __future__ import annotations
import base64
import hashlib
import secrets
def generate_pkce() -> tuple[str, str]:
"""生成 PKCE code_verifier 与对应的 code_challenge(S256)。
Returns:
(code_verifier, code_challenge) 元组
"""
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
+207
View File
@@ -0,0 +1,207 @@
import hashlib
import json
import random
import re
import time
from datetime import datetime, timedelta, timezone
from html.parser import HTMLParser
from typing import Any, Sequence
import pybase64
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
from utils.helper import new_uuid
CORES = [8, 16, 24, 32]
DOCUMENT_KEYS = ["__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"]
SCREEN_RESOLUTIONS = [[1920, 1080], [1440, 900], [2560, 1440], [3840, 2160]]
class ScriptSrcParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.script_sources: list[str] = []
self.data_build = ""
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag != "script":
return
attrs_dict = dict(attrs)
src = attrs_dict.get("src")
if not src:
return
self.script_sources.append(src)
match = re.search(r"c/[^/]*/_", src)
if match:
self.data_build = match.group(0)
def parse_pow_resources(html_content: str) -> tuple[list[str], str]:
parser = ScriptSrcParser()
parser.feed(html_content)
script_sources = parser.script_sources or [DEFAULT_POW_SCRIPT]
data_build = parser.data_build
if not data_build:
match = re.search(r'<html[^>]*data-build="([^"]*)"', html_content)
if match:
data_build = match.group(1)
return script_sources, data_build
def _legacy_parse_time() -> str:
now = datetime.now(timezone(timedelta(hours=-5)))
return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)"
def build_pow_config(
user_agent: str,
script_sources: Sequence[str] | None = None,
data_build: str = "",
) -> list[Any]:
navigator_key = random.choice([
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
"storage−[object StorageManager]",
"locks−[object LockManager]",
"appCodeName−Mozilla",
"permissions−[object Permissions]",
"share−function share() { [native code] }",
"webdriver−false",
"managed−[object NavigatorManagedData]",
"canShare−function canShare() { [native code] }",
"vendor−Google Inc.",
"mediaDevices−[object MediaDevices]",
"vibrate−function vibrate() { [native code] }",
"storageBuckets−[object StorageBucketManager]",
"mediaCapabilities−[object MediaCapabilities]",
"cookieEnabled−true",
"virtualKeyboard−[object VirtualKeyboard]",
"product−Gecko",
"presentation−[object Presentation]",
"onLine−true",
"mimeTypes−[object MimeTypeArray]",
"credentials−[object CredentialsContainer]",
"serviceWorker−[object ServiceWorkerContainer]",
"keyboard−[object Keyboard]",
"gpu−[object GPU]",
"doNotTrack",
"serial−[object Serial]",
"pdfViewerEnabled−true",
"language−zh-CN",
"geolocation−[object Geolocation]",
"userAgentData−[object NavigatorUAData]",
"getUserMedia−function getUserMedia() { [native code] }",
"sendBeacon−function sendBeacon() { [native code] }",
"hardwareConcurrency−32",
"windowControlsOverlay−[object WindowControlsOverlay]",
])
window_key = random.choice([
"0",
"window",
"self",
"document",
"name",
"location",
"customElements",
"history",
"navigation",
"innerWidth",
"innerHeight",
"scrollX",
"scrollY",
"visualViewport",
"screenX",
"screenY",
"outerWidth",
"outerHeight",
"devicePixelRatio",
"screen",
"chrome",
"navigator",
"onresize",
"performance",
"crypto",
"indexedDB",
"sessionStorage",
"localStorage",
"scheduler",
"alert",
"atob",
"btoa",
"fetch",
"matchMedia",
"postMessage",
"queueMicrotask",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"caches",
"__NEXT_DATA__",
"__BUILD_MANIFEST",
"__NEXT_PRELOADREADY",
])
script_source = random.choice(list(script_sources)) if script_sources else DEFAULT_POW_SCRIPT
return [
sum(random.choices(SCREEN_RESOLUTIONS, k=1)[0]),
_legacy_parse_time(),
4294705152,
1,
user_agent,
script_source,
data_build,
"en-US",
"en-US,es-US,en,es",
random.random(),
navigator_key,
random.choice(DOCUMENT_KEYS),
window_key,
time.perf_counter() * 1000,
new_uuid(),
"",
random.choice(CORES),
time.time() * 1000 - (time.perf_counter() * 1000),
0, 0, 0, 0, 0, 0,
0, # 0 = edge/chrome, 1 = firefox
]
def _pow_generate(seed: str, difficulty: str, config: list[Any], limit: int = 500000) -> tuple[str, bool]:
target = bytes.fromhex(difficulty)
diff_len = len(difficulty) // 2
seed_bytes = seed.encode()
static_1 = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode()
static_2 = ("," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + ",").encode()
static_3 = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode()
for i in range(limit):
final_json = static_1 + str(i).encode() + static_2 + str(i >> 1).encode() + static_3
encoded = pybase64.b64encode(final_json)
digest = hashlib.sha3_512(seed_bytes + encoded).digest()
if digest[:diff_len] <= target:
return encoded.decode(), True
fallback = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + pybase64.b64encode(f'"{seed}"'.encode()).decode()
return fallback, False
def build_legacy_requirements_token(
user_agent: str,
script_sources: Sequence[str] | None = None,
data_build: str = "",
) -> str:
config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build)
return "gAAAAAC" + pybase64.b64encode(
json.dumps(config, separators=(",", ":"), ensure_ascii=False).encode()
).decode()
def build_proof_token(
seed: str,
difficulty: str,
user_agent: str,
script_sources: Sequence[str] | None = None,
data_build: str = "",
) -> str:
config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build)
answer, solved = _pow_generate(seed, difficulty, config)
if not solved:
raise RuntimeError(f"failed to solve proof token: difficulty={difficulty}")
return "gAAAAAB" + answer
+159
View File
@@ -0,0 +1,159 @@
"""OpenAI Sentinel Token (PoW) 生成与请求工具函数。
用于密码登录、注册等需要 sentinel token 的流程。
"""
from __future__ import annotations
import base64
import json
import random
import time
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from curl_cffi.requests import Session
class SentinelTokenGenerator:
"""Sentinel Token 生成器(PoW - Proof of Work)。"""
MAX_ATTEMPTS = 500_000
ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
def __init__(self, device_id: str, ua: str):
self.device_id = device_id
self.user_agent = ua
self.sid = str(uuid.uuid4())
@staticmethod
def _fnv1a_32(text: str) -> str:
h = 2166136261
for ch in text:
h ^= ord(ch)
h = (h * 16777619) & 0xFFFFFFFF
h ^= h >> 16
h = (h * 2246822507) & 0xFFFFFFFF
h ^= h >> 13
h = (h * 3266489909) & 0xFFFFFFFF
h ^= h >> 16
return format(h & 0xFFFFFFFF, "08x")
def _get_config(self) -> list:
perf_now = random.uniform(1000, 50000)
return [
"1920x1080",
time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()),
4294705152,
random.random(),
self.user_agent,
"https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js",
None,
None,
"en-US",
random.random(),
random.choice(["vendorSub-undefined", "plugins-undefined", "mimeTypes-undefined", "hardwareConcurrency-undefined"]),
random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]),
random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]),
perf_now,
self.sid,
"",
random.choice([4, 8, 12, 16]),
time.time() * 1000 - perf_now,
]
@staticmethod
def _b64(data) -> str:
return base64.b64encode(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).decode("ascii")
def generate_requirements_token(self) -> str:
data = self._get_config()
data[3] = 1
data[9] = round(random.uniform(5, 50))
return "gAAAAAC" + self._b64(data)
def generate_token(self, seed: str, difficulty: str) -> str:
start = time.time()
data = self._get_config()
difficulty = str(difficulty or "0")
for i in range(self.MAX_ATTEMPTS):
data[3] = i
data[9] = round((time.time() - start) * 1000)
payload = self._b64(data)
if self._fnv1a_32(seed + payload)[: len(difficulty)] <= difficulty:
return "gAAAAAB" + payload + "~S"
return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None))
# ── 默认 User-Agent 和 sec-ch-ua ──────────────────────────────
DEFAULT_SENTINEL_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36"
)
DEFAULT_SENTINEL_SEC_CH_UA = '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"'
def build_sentinel_token(
session: "Session",
device_id: str,
flow: str,
*,
user_agent: str = "",
sec_ch_ua: str = "",
) -> tuple[str, str]:
"""请求 sentinel token 并返回 (sentinel_header_value, oai_sc_cookie_value)。
Args:
session: curl_cffi Session 实例
device_id: 设备 ID
flow: 流程标识(如 "password_verify", "username_password_create" 等)
user_agent: 可选的 User-Agent 覆盖
sec_ch_ua: 可选的 sec-ch-ua 覆盖
Returns:
(openai-sentinel-token header value, oai-sc cookie value) 元组
Raises:
RuntimeError: sentinel 请求失败
"""
ua = user_agent or DEFAULT_SENTINEL_USER_AGENT
ch_ua = sec_ch_ua or DEFAULT_SENTINEL_SEC_CH_UA
generator = SentinelTokenGenerator(device_id, ua)
resp = session.post(
"https://sentinel.openai.com/backend-api/sentinel/req",
data=json.dumps({"p": generator.generate_requirements_token(), "id": device_id, "flow": flow}),
headers={
"Content-Type": "text/plain;charset=UTF-8",
"Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
"Origin": "https://sentinel.openai.com",
"User-Agent": ua,
"sec-ch-ua": ch_ua,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
},
timeout=20,
verify=False,
)
try:
data = resp.json() if resp.text else {}
except Exception:
fallback = json.dumps(
{"p": generator.generate_requirements_token(), "t": "", "c": "", "id": device_id, "flow": flow},
separators=(",", ":"),
)
return fallback, ""
token = str(data.get("token") or "").strip()
if resp.status_code != 200 or not token:
raise RuntimeError(f"sentinel_req_failed_{resp.status_code}")
pow_data = data.get("proofofwork") or {}
p_value = (
generator.generate_token(str(pow_data.get("seed") or ""), str(pow_data.get("difficulty") or "0"))
if pow_data.get("required") and pow_data.get("seed")
else generator.generate_requirements_token()
)
sentinel_value = json.dumps({"p": p_value, "t": "", "c": token, "id": device_id, "flow": flow}, separators=(",", ":"))
# oai-sc cookie = "0" + sentinel token "c" value (the challenge token from the server)
oai_sc_value = "0" + token
return sentinel_value, oai_sc_value
+184
View File
@@ -0,0 +1,184 @@
import base64
import json
import random
import time
from typing import Any, Dict, Optional
class OrderedMap:
def __init__(self) -> None:
self.keys = []
self.values = {}
def add(self, key: str, value: Any) -> None:
if key not in self.values:
self.keys.append(key)
self.values[key] = value
def _turnstile_to_str(value: Any) -> str:
if value is None:
return "undefined"
if isinstance(value, float):
return str(value)
if isinstance(value, str):
special = {
"window.Math": "[object Math]",
"window.Reflect": "[object Reflect]",
"window.performance": "[object Performance]",
"window.localStorage": "[object Storage]",
"window.Object": "function Object() { [native code] }",
"window.Reflect.set": "function set() { [native code] }",
"window.performance.now": "function () { [native code] }",
"window.Object.create": "function create() { [native code] }",
"window.Object.keys": "function keys() { [native code] }",
"window.Math.random": "function random() { [native code] }",
}
return special.get(value, value)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return ",".join(value)
return str(value)
def _xor_string(text: str, key: str) -> str:
if not key:
return text
return "".join(chr(ord(ch) ^ ord(key[i % len(key)])) for i, ch in enumerate(text))
def solve_turnstile_token(dx: str, p: str) -> Optional[str]:
try:
decoded = base64.b64decode(dx).decode()
token_list = json.loads(_xor_string(decoded, p))
except Exception:
return None
process_map: Dict[Any, Any] = {}
start_time = time.time()
result = ""
def func_1(e: float, t: float) -> None:
process_map[e] = _xor_string(_turnstile_to_str(process_map[e]), _turnstile_to_str(process_map[t]))
def func_2(e: float, t: Any) -> None:
process_map[e] = t
def func_3(e: str) -> None:
nonlocal result
result = base64.b64encode(e.encode()).decode()
def func_5(e: float, t: float) -> None:
current = process_map[e]
incoming = process_map[t]
if isinstance(current, (list, tuple)):
process_map[e] = list(current) + [incoming]
return
if isinstance(current, (str, float)) or isinstance(incoming, (str, float)):
process_map[e] = _turnstile_to_str(current) + _turnstile_to_str(incoming)
return
process_map[e] = "NaN"
def func_6(e: float, t: float, n: float) -> None:
tv = process_map[t]
nv = process_map[n]
if isinstance(tv, str) and isinstance(nv, str):
value = f"{tv}.{nv}"
process_map[e] = "https://chatgpt.com/" if value == "window.document.location" else value
def func_7(e: float, *args: float) -> None:
target = process_map[e]
values = [process_map[arg] for arg in args]
if isinstance(target, str) and target == "window.Reflect.set":
obj, key_name, val = values
obj.add(str(key_name), val)
elif callable(target):
target(*values)
def func_8(e: float, t: float) -> None:
process_map[e] = process_map[t]
def func_14(e: float, t: float) -> None:
process_map[e] = json.loads(process_map[t])
def func_15(e: float, t: float) -> None:
process_map[e] = json.dumps(process_map[t])
def func_17(e: float, t: float, *args: float) -> None:
call_args = [process_map[arg] for arg in args]
target = process_map[t]
if target == "window.performance.now":
elapsed_ns = time.time_ns() - int(start_time * 1e9)
process_map[e] = (elapsed_ns + random.random()) / 1e6
elif target == "window.Object.create":
process_map[e] = OrderedMap()
elif target == "window.Object.keys":
if call_args and call_args[0] == "window.localStorage":
process_map[e] = [
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
"STATSIG_LOCAL_STORAGE_STABLE_ID",
"client-correlated-secret",
"oai/apps/capExpiresAt",
"oai-did",
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
"UiState.isNavigationCollapsed.1",
]
elif target == "window.Math.random":
process_map[e] = random.random()
elif callable(target):
process_map[e] = target(*call_args)
def func_18(e: float) -> None:
process_map[e] = base64.b64decode(_turnstile_to_str(process_map[e])).decode()
def func_19(e: float) -> None:
process_map[e] = base64.b64encode(_turnstile_to_str(process_map[e]).encode()).decode()
def func_20(e: float, t: float, n: float, *args: float) -> None:
if process_map[e] == process_map[t]:
target = process_map[n]
if callable(target):
target(*[process_map[arg] for arg in args])
def func_21(*_: Any) -> None:
return
def func_23(e: float, t: float, *args: float) -> None:
if process_map[e] is not None and callable(process_map[t]):
process_map[t](*args)
def func_24(e: float, t: float, n: float) -> None:
tv = process_map[t]
nv = process_map[n]
if isinstance(tv, str) and isinstance(nv, str):
process_map[e] = f"{tv}.{nv}"
process_map.update({
1: func_1,
2: func_2,
3: func_3,
5: func_5,
6: func_6,
7: func_7,
8: func_8,
9: token_list,
10: "window",
14: func_14,
15: func_15,
16: p,
17: func_17,
18: func_18,
19: func_19,
20: func_20,
21: func_21,
23: func_23,
24: func_24,
})
for token in token_list:
try:
fn = process_map.get(token[0])
if callable(fn):
fn(*token[1:])
except Exception:
continue
return result or None