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:
@@ -17,6 +17,14 @@ from fastgpt_client.exceptions import (
|
||||
StreamParseError,
|
||||
ValidationError,
|
||||
)
|
||||
from fastgpt_client.images import (
|
||||
decode_data_url,
|
||||
encode_image_data_url,
|
||||
image_part_from_bytes,
|
||||
image_part_from_path,
|
||||
image_url_part,
|
||||
is_data_url,
|
||||
)
|
||||
from fastgpt_client.streaming import aiter_stream_events, iter_stream_events
|
||||
from fastgpt_client.stream_types import FastGPTInteractiveEvent, FastGPTStreamEvent
|
||||
|
||||
@@ -41,6 +49,13 @@ __all__ = [
|
||||
"aiter_stream_events",
|
||||
"FastGPTStreamEvent",
|
||||
"FastGPTInteractiveEvent",
|
||||
# Image helpers
|
||||
"encode_image_data_url",
|
||||
"decode_data_url",
|
||||
"is_data_url",
|
||||
"image_url_part",
|
||||
"image_part_from_bytes",
|
||||
"image_part_from_path",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import weakref
|
||||
from typing import Any, Dict, Literal, Union
|
||||
@@ -10,6 +12,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 FastGPTClient(BaseClientMixin):
|
||||
@@ -261,6 +264,10 @@ class ChatClient(FastGPTClient):
|
||||
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.
|
||||
|
||||
@@ -271,12 +278,35 @@ class ChatClient(FastGPTClient):
|
||||
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 = 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,
|
||||
@@ -546,6 +576,95 @@ class ChatClient(FastGPTClient):
|
||||
outLinkAuthData=outLinkAuthData,
|
||||
)
|
||||
|
||||
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(
|
||||
self._upload_data_url(
|
||||
url,
|
||||
appId=appId,
|
||||
chatId=chatId,
|
||||
outLinkAuthData=outLinkAuthData,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_content.append(part)
|
||||
resolved.append({**message, "content": new_content})
|
||||
return resolved
|
||||
|
||||
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 = 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
|
||||
|
||||
def get_chat_histories(
|
||||
self,
|
||||
appId: str,
|
||||
|
||||
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)
|
||||
162
tests/test_image_inputs.py
Normal file
162
tests/test_image_inputs.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests for image-input helpers and the base64/upload handling modes."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastgpt_client import images
|
||||
from fastgpt_client.async_client import AsyncChatClient
|
||||
from fastgpt_client.client import ChatClient
|
||||
from fastgpt_client.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestImageHelpers:
|
||||
def test_encode_decode_round_trip(self):
|
||||
url = images.encode_image_data_url(b"hello", "image/png")
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
assert images.is_data_url(url)
|
||||
mime, raw = images.decode_data_url(url)
|
||||
assert mime == "image/png"
|
||||
assert raw == b"hello"
|
||||
|
||||
def test_is_data_url_rejects_plain_url(self):
|
||||
assert not images.is_data_url("https://example.com/a.png")
|
||||
assert not images.is_data_url(None)
|
||||
|
||||
def test_decode_rejects_non_data_url(self):
|
||||
with pytest.raises(ValueError):
|
||||
images.decode_data_url("https://example.com/a.png")
|
||||
|
||||
def test_decode_rejects_bad_base64(self):
|
||||
with pytest.raises(ValueError):
|
||||
images.decode_data_url("data:image/png;base64,!!!notbase64!!!")
|
||||
|
||||
def test_image_url_part_shape(self):
|
||||
assert images.image_url_part("u") == {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "u"},
|
||||
}
|
||||
|
||||
def test_image_part_from_path(self, tmp_path):
|
||||
f = tmp_path / "pic.png"
|
||||
f.write_bytes(b"\x89PNG\r\n")
|
||||
part = images.image_part_from_path(f)
|
||||
assert part["type"] == "image_url"
|
||||
assert part["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def _data_url():
|
||||
return images.encode_image_data_url(b"imgbytes", "image/jpeg")
|
||||
|
||||
|
||||
class TestSyncImageInputMode:
|
||||
def test_base64_mode_passes_through(self, api_key):
|
||||
client = ChatClient(api_key)
|
||||
url = _data_url()
|
||||
messages = [{"role": "user", "content": [images.image_url_part(url)]}]
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
with patch.object(client, "_send_request", return_value=mock_response) as send, \
|
||||
patch.object(client, "upload_chat_image") as upload:
|
||||
client.create_chat_completion(messages=messages)
|
||||
|
||||
upload.assert_not_called()
|
||||
sent = send.call_args[1]["json"]["messages"]
|
||||
assert sent[0]["content"][0]["image_url"]["url"] == url
|
||||
|
||||
def test_upload_mode_replaces_data_url(self, api_key):
|
||||
client = ChatClient(api_key)
|
||||
url = _data_url()
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"},
|
||||
images.image_url_part(url),
|
||||
images.image_url_part("https://keep/me.png"),
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
with patch.object(client, "_send_request", return_value=mock_response) as send, \
|
||||
patch.object(
|
||||
client, "upload_chat_image", return_value={"url": "https://cdn/up.jpg"}
|
||||
) as upload:
|
||||
client.create_chat_completion(
|
||||
messages=messages,
|
||||
chatId="chat-1",
|
||||
appId="app-1",
|
||||
image_input_mode="upload",
|
||||
)
|
||||
|
||||
upload.assert_called_once()
|
||||
sent = send.call_args[1]["json"]["messages"]
|
||||
assert sent[0]["content"][1]["image_url"]["url"] == "https://cdn/up.jpg"
|
||||
# plain URL part untouched
|
||||
assert sent[0]["content"][2]["image_url"]["url"] == "https://keep/me.png"
|
||||
# original messages not mutated
|
||||
assert messages[0]["content"][1]["image_url"]["url"] == url
|
||||
|
||||
def test_upload_mode_requires_app_and_chat_id(self, api_key):
|
||||
client = ChatClient(api_key)
|
||||
messages = [{"role": "user", "content": [images.image_url_part(_data_url())]}]
|
||||
with pytest.raises(ValidationError):
|
||||
client.create_chat_completion(messages=messages, image_input_mode="upload")
|
||||
|
||||
def test_invalid_mode_raises(self, api_key):
|
||||
client = ChatClient(api_key)
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
with pytest.raises(ValidationError):
|
||||
client.create_chat_completion(messages=messages, image_input_mode="nope")
|
||||
|
||||
def test_upload_failure_falls_back_to_base64(self, api_key):
|
||||
client = ChatClient(api_key)
|
||||
url = _data_url()
|
||||
messages = [{"role": "user", "content": [images.image_url_part(url)]}]
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
with patch.object(client, "_send_request", return_value=mock_response) as send, \
|
||||
patch.object(client, "upload_chat_image", side_effect=RuntimeError("boom")):
|
||||
client.create_chat_completion(
|
||||
messages=messages, chatId="c", appId="a", image_input_mode="upload"
|
||||
)
|
||||
|
||||
sent = send.call_args[1]["json"]["messages"]
|
||||
assert sent[0]["content"][0]["image_url"]["url"] == url
|
||||
|
||||
|
||||
class TestAsyncImageInputMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_mode_replaces_data_url(self, api_key):
|
||||
client = AsyncChatClient(api_key)
|
||||
url = _data_url()
|
||||
messages = [{"role": "user", "content": [images.image_url_part(url)]}]
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
with patch.object(client, "_send_request", new=AsyncMock(return_value=mock_response)) as send, \
|
||||
patch.object(
|
||||
client,
|
||||
"upload_chat_image",
|
||||
new=AsyncMock(return_value={"url": "https://cdn/u.jpg"}),
|
||||
):
|
||||
await client.create_chat_completion(
|
||||
messages=messages, chatId="c", appId="a", image_input_mode="upload"
|
||||
)
|
||||
|
||||
sent = send.call_args[1]["json"]["messages"]
|
||||
assert sent[0]["content"][0]["image_url"]["url"] == "https://cdn/u.jpg"
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_mode_requires_app_and_chat_id(self, api_key):
|
||||
client = AsyncChatClient(api_key)
|
||||
messages = [{"role": "user", "content": [images.image_url_part(_data_url())]}]
|
||||
with pytest.raises(ValidationError):
|
||||
await client.create_chat_completion(messages=messages, image_input_mode="upload")
|
||||
await client.close()
|
||||
Reference in New Issue
Block a user