Add a unified way to send images in chat: inline base64 data URLs (passed through natively) or auto-upload via image_input_mode="upload", which replaces inline data URLs with hosted URLs using upload_chat_image. - New fastgpt_client/images.py with content-part / data-URL helpers - image_input_mode + appId/outLinkAuthData params on create_chat_completion (sync and async); upload failures fall back to inline base64 - Tests covering helpers, both modes, validation, and fallback Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
"""Helpers for attaching images to FastGPT chat messages.
|
|
|
|
FastGPT is OpenAI-compatible and accepts two ways to send an image inside a
|
|
message's ``content`` list as an ``image_url`` part:
|
|
|
|
1. **Inline base64** — a ``data:<mime>;base64,<payload>`` data URL. Nothing is
|
|
uploaded; the image travels inside the request body. Cheapest to send and
|
|
requires no ``appId``/``chatId``.
|
|
2. **Uploaded URL** — upload the bytes first (see
|
|
:meth:`ChatClient.upload_chat_image`) and reference the returned URL. Keeps
|
|
the request body small and lets the image be reused/previewed later.
|
|
|
|
These helpers build the ``image_url`` content parts for either method and let
|
|
you convert an inline data URL back into raw bytes (used when uploading).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
__all__ = [
|
|
"encode_image_data_url",
|
|
"is_data_url",
|
|
"decode_data_url",
|
|
"image_url_part",
|
|
"image_part_from_bytes",
|
|
"image_part_from_path",
|
|
]
|
|
|
|
_DATA_URL_PREFIX = "data:"
|
|
|
|
|
|
def encode_image_data_url(data: bytes, mime_type: str = "image/jpeg") -> str:
|
|
"""Return a ``data:<mime>;base64,<payload>`` URL for raw image bytes."""
|
|
payload = base64.b64encode(data).decode("ascii")
|
|
return f"{_DATA_URL_PREFIX}{mime_type};base64,{payload}"
|
|
|
|
|
|
def is_data_url(url: Any) -> bool:
|
|
"""True if ``url`` is an inline base64 data URL (``data:...``)."""
|
|
return isinstance(url, str) and url.startswith(_DATA_URL_PREFIX)
|
|
|
|
|
|
def decode_data_url(data_url: str) -> tuple[str, bytes]:
|
|
"""Split a base64 data URL into ``(mime_type, raw_bytes)``.
|
|
|
|
Raises:
|
|
ValueError: if ``data_url`` is not a data URL or its base64 payload is
|
|
malformed.
|
|
"""
|
|
if not is_data_url(data_url):
|
|
raise ValueError("Not a base64 data URL")
|
|
header, _, payload = data_url.partition(",")
|
|
mime_type = header[len(_DATA_URL_PREFIX):].split(";", 1)[0].strip() or "image/jpeg"
|
|
try:
|
|
raw = base64.b64decode(payload, validate=True)
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise ValueError(f"Invalid base64 data URL payload: {exc}") from exc
|
|
return mime_type, raw
|
|
|
|
|
|
def image_url_part(url: str) -> dict[str, Any]:
|
|
"""Build an ``image_url`` content part from a URL or inline data URL."""
|
|
return {"type": "image_url", "image_url": {"url": url}}
|
|
|
|
|
|
def image_part_from_bytes(data: bytes, mime_type: str = "image/jpeg") -> dict[str, Any]:
|
|
"""Build an inline base64 ``image_url`` content part from raw bytes."""
|
|
return image_url_part(encode_image_data_url(data, mime_type))
|
|
|
|
|
|
def image_part_from_path(file_path: str | Path) -> dict[str, Any]:
|
|
"""Read a local image file and build an inline base64 ``image_url`` part."""
|
|
path = Path(file_path)
|
|
mime_type = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
|
return image_part_from_bytes(path.read_bytes(), mime_type)
|