Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
from astrbot.core.message.message_event_result import (
MessageChain,
)
from astrbot.core.utils.media_utils import (
ResolvedMediaData,
normalize_image_for_provider,
)
from astrbot.core.persona_error_reply import (
extract_persona_custom_error_message_from_event,
)
Expand Down Expand Up @@ -1039,10 +1043,25 @@ async def step(self):
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
)
)
normalized = normalize_image_for_provider(
ResolvedMediaData(
base64_data=base64_data,
mime_type=mime_type,
format=None,
)
)
if normalized is None:
logger.warning(
"Skip cached image for provider review: unsupported "
"tool image mime_type=%s path=%s",
mime_type,
cached_img.file_path,
)
continue
image_parts.append(
ImageURLPart(
image_url=ImageURLPart.ImageURL(
url=f"data:{mime_type};base64,{base64_data}",
url=normalized.to_data_url(),
id=cached_img.file_path,
)
)
Expand Down
31 changes: 24 additions & 7 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.utils.media_utils import (
describe_media_ref,
normalize_image_for_provider,
resolve_media_ref_to_base64_data,
)
from astrbot.core.utils.network_utils import (
Expand Down Expand Up @@ -174,6 +175,8 @@ def _is_invalid_attachment_error(self, error: Exception) -> bool:
return True
if "download attachment" in error_text and "404" in error_text:
return True
if "unsupported image" in error_text:
return True
return False

async def _image_ref_to_data_url(
Expand All @@ -195,11 +198,18 @@ async def _resolve_image_part(
*,
image_detail: str | None = None,
) -> dict | None:
image_data = await self._image_ref_to_data_url(image_url, mode="safe")
if not image_data:
logger.warning("图片预处理结果为空,将忽略。")
image_data = await resolve_media_ref_to_base64_data(
image_url,
media_type="image",
strict=False,
)
normalized = normalize_image_for_provider(image_data)
if normalized is None:
logger.warning(
"Image preprocessing returned no usable image; skipping image_url part."
)
return None
image_payload = {"url": image_data}
image_payload = {"url": normalized.to_data_url()}

if image_detail:
image_payload["detail"] = image_detail
Expand Down Expand Up @@ -281,13 +291,20 @@ async def _transform_content_part(self, part: dict) -> dict:
)
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
"Image %s preprocessing failed; replacing with text placeholder: %s",
url,
exc,
)
return part
return {"type": "text", "text": "[image omitted]"}

return resolved_part or part
if resolved_part is None:
logger.warning(
"Image %s cannot be converted to a supported format; "
"replacing with text placeholder.",
url,
)
return {"type": "text", "text": "[image omitted]"}
return resolved_part

if part.get("type") == "audio_url":
audio_ref = self._extract_audio_part_info(part)
Expand Down
82 changes: 82 additions & 0 deletions astrbot/core/utils/media_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,88 @@ def to_data_url(self) -> str:
return f"data:{self.mime_type};base64,{self.base64_data}"


IMAGE_PROVIDER_SUPPORTED_MIME_TYPES = frozenset(
{
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/gif",
}
)


def normalize_image_for_provider(
image_data: ResolvedMediaData | None,
supported_mimes: set[str] | frozenset[str] | None = None,
) -> ResolvedMediaData | None:
"""Normalize image data to a MIME type accepted by vision providers.

Args:
image_data: Resolved image data.
supported_mimes: MIME types accepted by the provider. Defaults to common
vision provider formats (webp/png/jpeg/gif).

Returns:
Normalized image data, or None if the image cannot be converted.
"""
if image_data is None:
return None

supported = supported_mimes or IMAGE_PROVIDER_SUPPORTED_MIME_TYPES

try:
raw = image_data.to_bytes()
with PILImage.open(io.BytesIO(raw)) as img:
actual_fmt = str(img.format or "").upper()
actual_mime_by_fmt = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp",
"GIF": "image/gif",
}
actual_mime = actual_mime_by_fmt.get(actual_fmt)
if actual_mime in supported:
# Still validate bytes; return the re-labeled payload so a
# mislabeled provider-safe MIME header is corrected.
return ResolvedMediaData(
base64_data=image_data.base64_data,
mime_type=actual_mime,
format=image_data.format,
)

# Convert unsupported formats to a provider-safe representation.
# Preserve transparency with PNG; otherwise use JPEG when available.
has_alpha = img.mode in ("RGBA", "LA", "P") or "transparency" in img.info
if has_alpha and "image/png" in supported:
converted_mime = "image/png"
output_format = "PNG"
img = img.convert("RGBA")
elif "image/jpeg" in supported:
converted_mime = "image/jpeg"
output_format = "JPEG"
img = img.convert("RGB")
elif "image/png" in supported:
converted_mime = "image/png"
output_format = "PNG"
img = img.convert("RGB")
else:
return None

output = io.BytesIO()
save_kwargs = {}
if output_format == "JPEG":
save_kwargs["quality"] = 95
img.save(output, format=output_format, **save_kwargs)
return ResolvedMediaData(
base64_data=base64.b64encode(output.getvalue()).decode("utf-8"),
mime_type=converted_mime,
format=None,
)
except Exception:
return None


@dataclass(slots=True)
class _LocalMediaFile:
path: Path
Expand Down
57 changes: 57 additions & 0 deletions tests/test_media_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,3 +871,60 @@ async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(

assert len(fake.calls) == 1
assert fake.calls[0]["sample_rate"] == 24000


def test_normalize_image_for_provider_converts_bmp_to_jpeg():
from PIL import Image as PILImage

buffer = BytesIO()
PILImage.new("RGB", (2, 2), (255, 0, 0)).save(buffer, format="BMP")
data = media_utils.ResolvedMediaData(
base64_data=base64.b64encode(buffer.getvalue()).decode("ascii"),
mime_type="image/bmp",
format=None,
)

normalized = media_utils.normalize_image_for_provider(data)

assert normalized is not None
assert normalized.mime_type == "image/jpeg"
with PILImage.open(BytesIO(normalized.to_bytes())) as image:
assert image.format == "JPEG"


def test_normalize_image_for_provider_returns_none_for_unparseable_image():
data = media_utils.ResolvedMediaData(
base64_data=base64.b64encode(b"not-an-image").decode("ascii"),
mime_type="image/svg+xml",
format=None,
)

assert media_utils.normalize_image_for_provider(data) is None


def test_normalize_image_for_provider_rejects_invalid_supported_mime():
data = media_utils.ResolvedMediaData(
base64_data=base64.b64encode(b"not-an-image").decode("ascii"),
mime_type="image/png",
format=None,
)

assert media_utils.normalize_image_for_provider(data) is None


def test_normalize_image_for_provider_preserves_supported_mime():
from PIL import Image as PILImage

buffer = BytesIO()
PILImage.new("RGB", (2, 2), (1, 2, 3)).save(buffer, format="PNG")
data = media_utils.ResolvedMediaData(
base64_data=base64.b64encode(buffer.getvalue()).decode("ascii"),
mime_type="image/jpg", # Mislabeled alias should be corrected.
format=None,
)

normalized = media_utils.normalize_image_for_provider(data)

assert normalized is not None
assert normalized.mime_type == "image/png"
assert normalized.base64_data == data.base64_data
Loading