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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user