feat: support base64 and upload image input modes in chat
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>
This commit is contained in:
80
fastgpt_client/images.py
Normal file
80
fastgpt_client/images.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user