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
+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."