Initial project import
This commit is contained in:
+476
@@ -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"")
|
||||
return "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user