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
1 change: 1 addition & 0 deletions astrbot/core/agent/context/compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ async def __call__(self, messages: list[Message]) -> list[Message]:
sanitized_summary_contexts, sanitize_stats = sanitize_contexts_by_modalities(
summary_contexts,
self.provider.provider_config.get("modalities", None),
self.provider.provider_config.get("supported_image_mimes", None),
)
log_context_sanitize_stats(sanitize_stats)

Expand Down
1 change: 1 addition & 0 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ def _sanitize_contexts_for_provider(
sanitized_contexts, stats = sanitize_contexts_by_modalities(
contexts,
self.provider.provider_config.get("modalities", None),
self.provider.provider_config.get("supported_image_mimes", None),
)
log_context_sanitize_stats(stats)
return sanitized_contexts
Expand Down
17 changes: 17 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,23 @@
"render_type": "checkbox",
"hint": "模型支持的模态及能力。",
},
"supported_image_mimes": {
"description": "支持的图片 MIME 类型",
"type": "list",
"items": {"type": "string"},
"options": [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
],
"labels": ["JPEG", "PNG", "WebP", "GIF"],
"render_type": "checkbox",
"hint": (
"模型支持的图片格式。不勾选时保留全部(仅过滤已知不安全格式如 GIF)。"
"勾选后仅保留勾选的格式。"
),
},
"custom_headers": {
"description": "自定义请求头",
"type": "dict",
Expand Down
122 changes: 119 additions & 3 deletions astrbot/core/provider/modalities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit

from astrbot import logger
from astrbot.core.agent.message import Message
Expand Down Expand Up @@ -34,10 +35,98 @@ def _message_to_dict(message: dict[str, Any] | Message) -> dict[str, Any] | None
return None


# Image MIME types that some OpenAI-compatible gateways reject even when the
# model claims image support. Animated GIFs in particular are accepted by raw
# Gemini (which wants image/heif/video/mp4 for animations) but rejected by
# several Gemini-flavored OpenAI proxies with "mime type is not supported".
# Keeping this list narrow avoids over-stripping providers that do accept GIF
# (e.g. Anthropic Claude). See issue #9295.
_UNSUPPORTED_IMAGE_MIMES = frozenset({"image/gif"})

# Lazily-built extension → MIME mapping used only for http(s) URL fallback.
_IMAGE_EXT_TO_MIME: dict[str, str] = {
".gif": "image/gif",
}


def _extract_image_mime(part: dict[str, Any]) -> str | None:
"""Best-effort extraction of an image MIME type from a multimodal part.

Handles the OpenAI-style ``{"image_url": {"url": ...}}`` and the Anthropic /
Gemini-style ``{"source": {"media_type": ...}}`` / ``{"mimeType": ...}``
layouts, as well as a bare ``{"url": ...}`` or ``{"image_url": "<url>"}``.

Returns:
The normalized MIME type (e.g. ``image/gif``) if it can be determined,
otherwise ``None``.
"""
image_url = part.get("image_url")
if isinstance(image_url, dict):
url = image_url.get("url")
else:
url = image_url
if not isinstance(url, str):
url = part.get("url") if isinstance(part.get("url"), str) else None

if isinstance(url, str):
url = url.strip()
# data URLs look like "data:image/gif;base64,...."
if url.lower().startswith("data:"):
Comment on lines +71 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To make the MIME extraction more robust against leading or trailing whitespace in URLs (which can occasionally occur in raw or user-submitted contexts), consider stripping the url string before processing.

Suggested change
if isinstance(url, str):
# data URLs look like "data:image/gif;base64,...."
if url.lower().startswith("data:"):
if isinstance(url, str):
url = url.strip()
# data URLs look like "data:image/gif;base64,...."
if url.lower().startswith("data:"):

head = url[5:].split(",", 1)[0]
# head is e.g. "image/gif;base64"
mime = head.split(";", 1)[0].strip().lower()
if mime:
return mime
else:
# Fall back to the URL path extension for http(s) URLs. urlsplit
# robustly separates the path from query/fragment even when those
# delimiters appear percent-encoded inside the path itself.
path = urlsplit(url).path.lower()
for ext, mime in _IMAGE_EXT_TO_MIME.items():
if path.endswith(ext):
return mime

source = part.get("source")
if isinstance(source, dict):
media_type = source.get("media_type")
if isinstance(media_type, str):
# Normalize by stripping parameters (e.g. "image/gif; charset=binary"
# -> "image/gif") so it matches _UNSUPPORTED_IMAGE_MIMES.
return media_type.split(";", 1)[0].strip().lower()

mime_type = part.get("mimeType") or part.get("mime_type")
if isinstance(mime_type, str):
# Strip parameters (e.g. "image/gif;codec=xyz" -> "image/gif") to align
# with the data-URL and source.media_type handling above.
return mime_type.split(";", 1)[0].strip().lower()

return None


def _is_unsupported_image_mime(mime: str | None) -> bool:
"""Return True when the MIME is known to be rejected by some providers."""
return bool(mime) and mime in _UNSUPPORTED_IMAGE_MIMES


def sanitize_contexts_by_modalities(
contexts: Sequence[dict[str, Any] | Message],
modalities: list[str] | None,
supported_image_mimes: list[str] | None = None,
) -> tuple[list[dict[str, Any]], ContextSanitizeStats]:
"""Sanitize message contexts based on provider capabilities.

Args:
contexts: The message contexts to sanitize.
modalities: List of modalities the provider supports (e.g. ["text", "image"]).
supported_image_mimes: Whitelist of image MIME types the provider accepts.
When None or empty, a fallback blocklist (_UNSUPPORTED_IMAGE_MIMES) is used
to filter known-problematic formats (e.g. image/gif for some Gemini gateways).
When provided, images with MIME types not in this list are replaced with
"[Image]" placeholders.

Returns:
Tuple of (sanitized contexts, statistics).
"""
if not contexts:
return [], ContextSanitizeStats()
if not modalities or not isinstance(modalities, list):
Expand All @@ -51,7 +140,13 @@ def sanitize_contexts_by_modalities(
supports_image = "image" in modalities
supports_audio = "audio" in modalities
supports_tool_use = "tool_use" in modalities
if supports_image and supports_audio and supports_tool_use:
# Determine whether we need a MIME-filtering pass. When a whitelist is
# provided, we always filter; otherwise we fall back to the hardcoded
# blocklist for known-problematic MIME types (e.g. image/gif).
needs_mime_pass = supports_image and (
bool(supported_image_mimes) or bool(_UNSUPPORTED_IMAGE_MIMES)
)
if supports_image and supports_audio and supports_tool_use and not needs_mime_pass:
copied_contexts = []
for msg in contexts:
copied_msg = _message_to_dict(msg)
Expand Down Expand Up @@ -83,15 +178,36 @@ def sanitize_contexts_by_modalities(
msg.pop("tool_calls", None)
msg.pop("tool_call_id", None)

if not supports_image or not supports_audio:
if not supports_image or not supports_audio or needs_mime_pass:
content = msg.get("content")
if isinstance(content, list):
filtered_parts: list[Any] = []
removed_any_multimodal = False
for part in content:
if isinstance(part, dict):
part_type = str(part.get("type", "")).lower()
if not supports_image and part_type in {"image_url", "image"}:
# Determine whether this image part should be dropped.
should_drop_image = False
if part_type in {"image_url", "image"}:
if not supports_image:
# Provider declares no image support at all.
should_drop_image = True
else:
# Provider supports image, but may have MIME restrictions.
mime = _extract_image_mime(part)
if supported_image_mimes:
# Whitelist mode: drop if MIME not in the list.
if mime not in supported_image_mimes:
should_drop_image = True
else:
# Fallback blocklist mode: drop if MIME is known
# to be rejected by some gateways (e.g. image/gif).
if _is_unsupported_image_mime(mime):
should_drop_image = True
if should_drop_image:
# Replacing the block with a placeholder prevents unsupported
# bytes from being persisted into the session history and
# poisoning all subsequent requests. See issue #9295.
removed_any_multimodal = True
stats.fixed_image_blocks += 1
filtered_parts.append({"type": "text", "text": "[Image]"})
Expand Down
Loading