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:
Xin Wang
2026-06-04 09:50:37 +08:00
parent 4e980a3dd0
commit 07f30af105
5 changed files with 495 additions and 0 deletions

View File

@@ -3,6 +3,8 @@
import asyncio
import logging
import mimetypes
import os
import tempfile
from pathlib import Path
import weakref
from typing import Any, Dict, Literal, Union
@@ -11,6 +13,7 @@ import httpx
from .base_client import BaseClientMixin
from .exceptions import APIError, AuthenticationError, RateLimitError, ValidationError
from .images import decode_data_url, image_url_part, is_data_url
class AsyncFastGPTClient(BaseClientMixin):
@@ -317,6 +320,10 @@ class AsyncChatClient(AsyncFastGPTClient):
detail: bool = False,
variables: dict[str, Any] | None = None,
responseChatItemId: str | None = None,
*,
image_input_mode: Literal["base64", "upload"] = "base64",
appId: str | None = None,
outLinkAuthData: dict[str, Any] | None = None,
):
"""Create a chat completion.
@@ -327,12 +334,35 @@ class AsyncChatClient(AsyncFastGPTClient):
detail: Whether to return detailed response data
variables: Template variables for substitution
responseChatItemId: Custom ID for the response message
image_input_mode: How to deliver inline base64 ``image_url`` parts.
``"base64"`` (default) sends the data URL as-is. ``"upload"``
uploads each inline data URL via :meth:`upload_chat_image` and
replaces it with the hosted URL (requires ``appId`` and
``chatId``). Image parts that already reference a plain URL are
left untouched in both modes.
appId: Application ID, required when ``image_input_mode="upload"``.
outLinkAuthData: Optional share-link auth payload forwarded to the
upload requests in ``"upload"`` mode.
Returns:
httpx.Response object
"""
self._validate_params(messages=messages)
if image_input_mode == "upload":
if not appId or not chatId:
raise ValidationError(
"image_input_mode='upload' requires both appId and chatId"
)
messages = await self._resolve_image_inputs(
messages,
appId=appId,
chatId=chatId,
outLinkAuthData=outLinkAuthData,
)
elif image_input_mode != "base64":
raise ValidationError("image_input_mode must be 'base64' or 'upload'")
data = {
"messages": messages,
"stream": stream,
@@ -592,6 +622,95 @@ class AsyncChatClient(AsyncFastGPTClient):
outLinkAuthData=outLinkAuthData,
)
async def _resolve_image_inputs(
self,
messages: list[dict],
*,
appId: str,
chatId: str,
outLinkAuthData: dict[str, Any] | None = None,
) -> list[dict]:
"""Upload inline base64 ``image_url`` parts and swap in the hosted URLs.
Returns new message/content objects; the input ``messages`` are never
mutated. Parts whose URL is not an inline data URL are passed through.
"""
resolved: list[dict] = []
for message in messages:
content = message.get("content")
if not isinstance(content, list):
resolved.append(message)
continue
new_content: list[Any] = []
for part in content:
url = (
part.get("image_url", {}).get("url")
if isinstance(part, dict) and part.get("type") == "image_url"
else None
)
if is_data_url(url):
new_content.append(
image_url_part(
await self._upload_data_url(
url,
appId=appId,
chatId=chatId,
outLinkAuthData=outLinkAuthData,
)
)
)
else:
new_content.append(part)
resolved.append({**message, "content": new_content})
return resolved
async def _upload_data_url(
self,
data_url: str,
*,
appId: str,
chatId: str,
outLinkAuthData: dict[str, Any] | None = None,
) -> str:
"""Upload a ``data:image/...;base64,...`` URL and return the hosted URL.
Falls back to the original data URL if decoding or upload fails so the
request can still proceed with inline base64.
"""
try:
mime_type, raw = decode_data_url(data_url)
except ValueError as exc:
self.logger.warning("Skipping image upload; invalid base64 data URL: %s", exc)
return data_url
suffix = mimetypes.guess_extension(mime_type) or ".jpg"
tmp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(raw)
tmp_path = tmp.name
result = await self.upload_chat_image(
appId=appId,
chatId=chatId,
file_path=tmp_path,
outLinkAuthData=outLinkAuthData,
)
url = result.get("url") if isinstance(result, dict) else None
if isinstance(url, str) and url:
return url
self.logger.warning("Image upload returned no url; using inline base64")
return data_url
except Exception as exc: # noqa: BLE001 - graceful fallback to inline base64
self.logger.warning("Image upload failed; using inline base64: %s", exc)
return data_url
finally:
if tmp_path is not None:
try:
os.unlink(tmp_path)
except OSError:
pass
async def get_chat_histories(
self,
appId: str,