diff --git a/astrbot/core/agent/context/compressor.py b/astrbot/core/agent/context/compressor.py
index 759604dd93..7aaa7adf50 100644
--- a/astrbot/core/agent/context/compressor.py
+++ b/astrbot/core/agent/context/compressor.py
@@ -1,10 +1,20 @@
+from pathlib import Path
from typing import TYPE_CHECKING, Protocol, runtime_checkable
+from astrbot.core.exceptions import ProviderRequestTooLargeError
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
+
from ...provider.modalities import (
log_context_sanitize_stats,
sanitize_contexts_by_modalities,
)
from ..message import Message
+from .image_budget import get_image_encoded_byte_limit, validate_context_image_bytes
from .token_counter import EstimateTokenCounter, TokenCounter
if TYPE_CHECKING:
@@ -130,6 +140,7 @@ def __init__(
instruction_text: str | None = None,
compression_threshold: float = 0.82,
token_counter: TokenCounter | None = None,
+ image_media_store: ImageMediaStore | None = None,
) -> None:
"""Initialize the LLM summary compressor.
@@ -139,8 +150,10 @@ def __init__(
exact context. Clamped to 0-0.3.
instruction_text: Custom instruction for summary generation.
compression_threshold: The compression trigger threshold (default: 0.82).
+ image_media_store: Store shared with the main request.
"""
self.provider = provider
+ self.image_media_store = image_media_store
self.keep_recent_ratio = min(max(float(keep_recent_ratio), 0.0), 0.3)
self.compression_threshold = compression_threshold
self.token_counter = token_counter or EstimateTokenCounter()
@@ -273,11 +286,29 @@ async def __call__(self, messages: list[Message]) -> list[Message]:
# Generate summary
try:
+ limit = get_image_encoded_byte_limit(
+ getattr(self.provider, "provider_settings", {})
+ )
+ validate_context_image_bytes(sanitized_summary_contexts, limit)
+ sanitized_summary_contexts = await materialize_image_media_refs(
+ sanitized_summary_contexts,
+ self.image_media_store
+ or ImageMediaStore(Path(get_astrbot_data_path()) / "media"),
+ )
response = await self.provider.text_chat(
contexts=sanitized_summary_contexts,
)
summary_content = (response.completion_text or "").strip()
+ except (MemoryError, ImagePayloadTooLargeError, ProviderRequestTooLargeError):
+ raise
except Exception as e:
+ status_code = getattr(e, "status_code", None) or getattr(
+ getattr(e, "response", None), "status_code", None
+ )
+ if status_code == 413:
+ raise ProviderRequestTooLargeError(
+ "The summary provider rejected the request size (HTTP 413)."
+ ) from e
logger.error(f"Failed to generate summary: {e}")
return messages
diff --git a/astrbot/core/agent/context/config.py b/astrbot/core/agent/context/config.py
index aa216d9a25..e2679912b7 100644
--- a/astrbot/core/agent/context/config.py
+++ b/astrbot/core/agent/context/config.py
@@ -6,6 +6,7 @@
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
+ from astrbot.core.utils.image_media_store import ImageMediaStore
@dataclass
@@ -33,3 +34,5 @@ class ContextConfig:
"""Custom token counting method. If None, the default method is used."""
custom_compressor: ContextCompressor | None = None
"""Custom context compression method. If None, the default method is used."""
+ image_media_store: "ImageMediaStore | None" = None
+ """Application media store shared by main and summary requests."""
diff --git a/astrbot/core/agent/context/image_budget.py b/astrbot/core/agent/context/image_budget.py
new file mode 100644
index 0000000000..d02eb807ff
--- /dev/null
+++ b/astrbot/core/agent/context/image_budget.py
@@ -0,0 +1,87 @@
+"""Validate image bytes independently from text-token estimates."""
+
+from collections.abc import Sequence
+
+from astrbot.core.agent.message import ImageMediaRefPart, ImageURLPart, Message
+from astrbot.core.utils.media_utils import (
+ IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES,
+ ImagePayloadTooLargeError,
+)
+
+
+def get_image_encoded_byte_limit(provider_settings: dict | None) -> int:
+ """Read the configured per-image limit with the same rules for every request.
+
+ Args:
+ provider_settings: Settings belonging to the actual request provider.
+
+ Returns:
+ A positive byte limit, or the default when the setting is invalid.
+ """
+ options = (
+ provider_settings.get("image_compress_options", {})
+ if isinstance(provider_settings, dict)
+ else {}
+ )
+ limit = options.get("max_encoded_bytes") if isinstance(options, dict) else None
+ if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0:
+ return limit
+ return IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES
+
+
+def validate_context_image_bytes(
+ messages: Sequence[Message | dict],
+ max_encoded_bytes: int = IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES,
+) -> int:
+ """Validate selected images without loading or rewriting historical bytes.
+
+ Args:
+ messages: Selected main or summary request messages.
+ max_encoded_bytes: Maximum Base64 bytes for a single image.
+
+ Returns:
+ Total known encoded-image bytes, excluding JSON and data URI headers.
+ Remote URLs have unknown size until resolved and are not counted.
+
+ Raises:
+ ImagePayloadTooLargeError: A selected image exceeds the single-image cap.
+ ValueError: The configured cap is invalid.
+ """
+ if isinstance(max_encoded_bytes, bool) or max_encoded_bytes < 1:
+ raise ValueError("Image byte budget must be a positive integer")
+ total = 0
+ for message in messages:
+ parts = (
+ message.content if isinstance(message, Message) else message.get("content")
+ )
+ if not isinstance(parts, list):
+ continue
+ for part in parts:
+ size = 0
+ url = None
+ if isinstance(part, ImageMediaRefPart):
+ size = 4 * ((part.byte_size + 2) // 3)
+ elif isinstance(part, ImageURLPart):
+ url = part.image_url.url
+ elif isinstance(part, dict):
+ if part.get("type") == "image_media_ref":
+ size = 4 * ((int(part["byte_size"]) + 2) // 3)
+ elif part.get("type") == "image_url":
+ image_url = part.get("image_url")
+ url = (
+ image_url.get("url")
+ if isinstance(image_url, dict)
+ else image_url
+ )
+ if isinstance(url, str) and url.startswith("data:image/"):
+ comma = url.find(",")
+ if comma >= 0 and ";base64" in url[:comma]:
+ # Do not slice the full Base64 suffix merely to count it.
+ size = len(url) - comma - 1
+ if size > max_encoded_bytes:
+ raise ImagePayloadTooLargeError(
+ f"A selected image uses {size} Base64 bytes, exceeding the "
+ f"{max_encoded_bytes}-byte limit. Historical images were not changed."
+ )
+ total += size
+ return total
diff --git a/astrbot/core/agent/context/manager.py b/astrbot/core/agent/context/manager.py
index 1a11ebff96..fe98f3b7b2 100644
--- a/astrbot/core/agent/context/manager.py
+++ b/astrbot/core/agent/context/manager.py
@@ -1,4 +1,6 @@
from astrbot import logger
+from astrbot.core.exceptions import ProviderRequestTooLargeError
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
from ..message import Message
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
@@ -36,6 +38,7 @@ def __init__(
keep_recent_ratio=config.llm_compress_keep_recent_ratio,
instruction_text=config.llm_compress_instruction,
token_counter=self.token_counter,
+ image_media_store=config.image_media_store,
)
else:
self.compressor = TruncateByTurnsCompressor(
@@ -76,6 +79,8 @@ async def process(
result = await self._run_compression(result, total_tokens)
return result
+ except (MemoryError, ImagePayloadTooLargeError, ProviderRequestTooLargeError):
+ raise
except Exception as e:
logger.error(f"Error during context processing: {e}", exc_info=True)
return messages
diff --git a/astrbot/core/agent/context/token_counter.py b/astrbot/core/agent/context/token_counter.py
index 7c60cb23ec..c93e958c87 100644
--- a/astrbot/core/agent/context/token_counter.py
+++ b/astrbot/core/agent/context/token_counter.py
@@ -1,7 +1,14 @@
import json
from typing import Protocol, runtime_checkable
-from ..message import AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart
+from ..message import (
+ AudioURLPart,
+ ImageMediaRefPart,
+ ImageURLPart,
+ Message,
+ TextPart,
+ ThinkPart,
+)
@runtime_checkable
@@ -60,7 +67,7 @@ def count_tokens(
total += self._estimate_tokens(part.text)
elif isinstance(part, ThinkPart):
total += self._estimate_tokens(part.think)
- elif isinstance(part, ImageURLPart):
+ elif isinstance(part, (ImageURLPart, ImageMediaRefPart)):
total += IMAGE_TOKEN_ESTIMATE
elif isinstance(part, AudioURLPart):
total += AUDIO_TOKEN_ESTIMATE
diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py
index 4292f4c04e..a754692786 100644
--- a/astrbot/core/agent/message.py
+++ b/astrbot/core/agent/message.py
@@ -120,11 +120,33 @@ class ImageURL(BaseModel):
"""The URL of the image, can be data URI scheme like `data:image/png;base64,...`."""
id: str | None = None
"""The ID of the image, to allow LLMs to distinguish different images."""
+ detail: str | None = None
+
+ @model_serializer(mode="wrap")
+ def serialize(self, handler):
+ data = handler(self)
+ if self.detail is None:
+ data.pop("detail", None)
+ return data
type: str = "image_url"
image_url: ImageURL
+class ImageMediaRefPart(ContentPart):
+ """A durable image reference that is resolved only for a provider request."""
+
+ type: str = "image_media_ref"
+ media_id: str
+ mime_type: str
+ width: int | None = None
+ height: int | None = None
+ byte_size: int
+ detail: str | None = None
+ version: int = 1
+ image_id: str | None = None
+
+
class AudioURLPart(ContentPart):
"""
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
diff --git a/astrbot/core/agent/runners/coze/coze_agent_runner.py b/astrbot/core/agent/runners/coze/coze_agent_runner.py
index 801031d963..c505d953d6 100644
--- a/astrbot/core/agent/runners/coze/coze_agent_runner.py
+++ b/astrbot/core/agent/runners/coze/coze_agent_runner.py
@@ -1,16 +1,29 @@
import json
import sys
import typing as T
+from dataclasses import replace
+from pathlib import Path
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
+from astrbot.core.agent.context.image_budget import validate_context_image_bytes
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
-from astrbot.core.utils.media_utils import MediaResolver, describe_media_ref
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationOptions,
+ describe_media_ref,
+ resolve_media_ref_to_base64_data,
+)
from ...hooks import BaseAgentRunHooks
from ...message import is_checkpoint_message
@@ -91,6 +104,11 @@ async def step(self):
# 执行 Coze 请求并处理结果
async for response in self._execute_coze_request():
yield response
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ # Keep resource failures visible to the caller; do not turn them into
+ # a normal agent response that could trigger an unrelated retry.
+ self._transition_state(AgentState.ERROR)
+ raise
except Exception as e:
logger.error(f"Coze 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
@@ -148,6 +166,21 @@ async def _execute_coze_request(self):
# 处理历史上下文
if not self.auto_save_history and contexts:
+ image_options = (
+ self.req.image_preparation_options or ImagePreparationOptions()
+ )
+ image_limit = image_options.max_encoded_bytes
+ if image_limit is None:
+ image_limit = ImagePreparationOptions().max_encoded_bytes
+ validate_context_image_bytes(
+ contexts,
+ image_limit,
+ )
+ contexts = await materialize_image_media_refs(
+ contexts,
+ ImageMediaStore(Path(get_astrbot_data_path()) / "media"),
+ )
+ history_image_options = replace(image_options, enabled=False)
for ctx in contexts:
if is_checkpoint_message(ctx):
continue
@@ -169,7 +202,9 @@ async def _execute_coze_request(self):
if url:
file_id = (
await self._download_and_upload_image(
- url, session_id
+ url,
+ session_id,
+ image_options=history_image_options,
)
)
processed_content.append(
@@ -179,6 +214,12 @@ async def _execute_coze_request(self):
"file_url": url,
}
)
+ except (
+ ImagePayloadTooLargeError,
+ MemoryError,
+ OSError,
+ ):
+ raise
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
continue
@@ -221,6 +262,8 @@ async def _execute_coze_request(self):
"file_id": file_id,
}
)
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ raise
except Exception as e:
logger.warning(
"处理图片失败 %s: %s",
@@ -335,12 +378,19 @@ async def _download_and_upload_image(
self,
image_url: str,
session_id: str | None = None,
+ *,
+ image_options: ImagePreparationOptions | None = None,
) -> str:
"""下载图片并上传到 Coze,返回 file_id"""
import hashlib
- # 计算哈希实现缓存
- cache_key = hashlib.md5(image_url.encode("utf-8")).hexdigest()
+ image_options = image_options or getattr(
+ getattr(self, "req", None), "image_preparation_options", None
+ )
+ # Include preparation options so a changed byte budget cannot reuse an old upload.
+ cache_key = hashlib.sha256(
+ f"{image_url}\0{image_options!r}".encode()
+ ).hexdigest()
if session_id:
if session_id not in self.file_id_cache:
@@ -352,10 +402,15 @@ async def _download_and_upload_image(
return file_id
try:
- image_bytes = await MediaResolver(
+ image_data = await resolve_media_ref_to_base64_data(
image_url,
media_type="image",
- ).to_bytes()
+ strict=True,
+ image_options=image_options,
+ )
+ if image_data is None:
+ raise ValueError("Image preprocessing returned no data")
+ image_bytes = image_data.to_bytes()
file_id = await self.api_client.upload_file(image_bytes)
if session_id:
@@ -364,6 +419,8 @@ async def _download_and_upload_image(
return file_id
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ raise
except Exception as e:
logger.error("处理图片失败 %s: %s", describe_media_ref(image_url), e)
raise Exception(f"处理图片失败: {e!s}") from e
diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
index 4e3a40c5a0..40ab5d87ae 100644
--- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
+++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
@@ -16,6 +16,10 @@
ProviderRequest,
)
from astrbot.core.utils.config_number import coerce_int_config
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationOptions,
+)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
@@ -298,6 +302,11 @@ async def step(self):
except asyncio.CancelledError:
# Let caller manage cancellation semantics.
raise
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ # Keep resource failures visible to the caller; do not turn them into
+ # a normal agent response that could trigger an unrelated retry.
+ self._transition_state(AgentState.ERROR)
+ raise
except Exception as e:
err_msg = self._format_exception(e)
logger.error(f"DeerFlow request failed: {err_msg}", exc_info=True)
@@ -416,6 +425,7 @@ async def _build_messages_resolved(
prompt: str,
image_urls: list[str],
system_prompt: str | None,
+ image_options: ImagePreparationOptions | None = None,
) -> list[dict[str, T.Any]]:
"""Build DeerFlow messages after materializing image references.
@@ -423,6 +433,7 @@ async def _build_messages_resolved(
prompt: User prompt text.
image_urls: Image references accepted by MediaResolver.
system_prompt: Optional system prompt prepended to the request.
+ image_options: Preparation limits for the active provider request.
Returns:
Messages payload for DeerFlow.
@@ -434,7 +445,11 @@ async def _build_messages_resolved(
messages.append(
{
"role": "user",
- "content": await build_user_content_resolved(prompt, image_urls),
+ "content": await build_user_content_resolved(
+ prompt,
+ image_urls,
+ image_options=image_options,
+ ),
},
)
return messages
@@ -497,6 +512,9 @@ async def _build_payload_resolved(
"""
runtime_configurable = self._build_runtime_configurable(thread_id)
+ image_options = getattr(
+ getattr(self, "req", None), "image_preparation_options", None
+ )
return {
"assistant_id": self.assistant_id,
"input": {
@@ -504,6 +522,7 @@ async def _build_payload_resolved(
prompt,
image_urls,
system_prompt,
+ image_options=image_options,
),
},
"stream_mode": ["values", "messages-tuple", "custom"],
diff --git a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
index 621fac226b..9ae19dbcac 100644
--- a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
+++ b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
@@ -6,6 +6,8 @@
from astrbot import logger
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationOptions,
describe_media_ref,
resolve_media_ref_to_base64_data,
)
@@ -98,13 +100,18 @@ def build_user_content(prompt: str, image_urls: list[str]) -> Any:
return content
-async def build_user_content_resolved(prompt: str, image_urls: list[str]) -> Any:
+async def build_user_content_resolved(
+ prompt: str,
+ image_urls: list[str],
+ image_options: ImagePreparationOptions | None = None,
+) -> Any:
"""Build DeerFlow user content after resolving all supported image refs.
Args:
prompt: User text to include before image blocks.
image_urls: Image references from plugins or message attachments. Supports
local paths, HTTP(S), file URIs, base64://, data URIs, and bare base64.
+ image_options: Preparation limits for the active provider request.
Returns:
Plain text when no images are present; otherwise a multimodal content list.
@@ -136,7 +143,10 @@ async def build_user_content_resolved(prompt: str, image_urls: list[str]) -> Any
image_data = await resolve_media_ref_to_base64_data(
image_ref,
media_type="image",
+ image_options=image_options,
)
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ raise
except Exception as exc:
skipped_invalid_images += 1
logger.debug(
diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py
index c2875c70a4..cfe7a614f5 100644
--- a/astrbot/core/agent/runners/dify/dify_agent_runner.py
+++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py
@@ -8,7 +8,11 @@
LLMResponse,
ProviderRequest,
)
-from astrbot.core.utils.media_utils import MediaResolver
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ MediaResolver,
+ resolve_media_ref_to_base64_data,
+)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
@@ -80,6 +84,11 @@ async def step(self):
# 执行 Dify 请求并处理结果
async for response in self._execute_dify_request():
yield response
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ # Keep resource failures visible to the caller; do not turn them into
+ # a normal agent response that could trigger an unrelated retry.
+ self._transition_state(AgentState.ERROR)
+ raise
except Exception as e:
logger.error(f"Dify 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
@@ -108,10 +117,15 @@ async def _upload_image_for_dify(
image_url: str,
session_id: str,
) -> dict[str, str] | None:
- image_data = await MediaResolver(
+ image_options = getattr(
+ getattr(self, "req", None), "image_preparation_options", None
+ )
+ image_data = await resolve_media_ref_to_base64_data(
image_url,
media_type="image",
- ).to_base64_data(strict=True)
+ strict=True,
+ image_options=image_options,
+ )
if image_data is None:
logger.warning("Dify 图片预处理结果为空,将忽略。")
return None
@@ -159,6 +173,8 @@ async def _execute_dify_request(self):
for image_url in image_urls:
try:
image_payload = await self._upload_image_for_dify(image_url, session_id)
+ except (ImagePayloadTooLargeError, MemoryError, OSError):
+ raise
except Exception as e:
logger.warning(f"上传图片失败:{e}")
continue
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index c9787ed6f0..8927378249 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -28,7 +28,7 @@
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
-from astrbot.core.exceptions import EmptyModelOutputError
+from astrbot.core.exceptions import EmptyModelOutputError, ProviderRequestTooLargeError
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -46,9 +46,25 @@
sanitize_contexts_by_modalities,
)
from astrbot.core.provider.provider import Provider
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+ persist_inline_image_refs,
+)
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationInput,
+ get_image_preparation_options,
+ prepare_image_source,
+)
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
+from ..context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
from ..context.manager import ContextManager
from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..hooks import BaseAgentRunHooks
@@ -229,9 +245,13 @@ async def reset(
request_max_retries: int | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
+ image_media_store: ImageMediaStore | None = None,
**kwargs: T.Any,
) -> None:
self.req = request
+ self.image_media_store = image_media_store or ImageMediaStore(
+ Path(get_astrbot_data_path()) / "media"
+ )
self.streaming = streaming
self.enforce_max_turns = enforce_max_turns
self.llm_compress_instruction = llm_compress_instruction
@@ -255,6 +275,7 @@ async def reset(
llm_compress_provider=self.llm_compress_provider,
custom_token_counter=self.custom_token_counter,
custom_compressor=self.custom_compressor,
+ image_media_store=self.image_media_store,
)
self.request_context_manager = ContextManager(
self.request_context_manager_config
@@ -314,6 +335,12 @@ async def reset(
or request.extra_user_content_parts
):
m = await self._assemble_request_context_for_provider(request)
+ if self.image_media_store is not None:
+ m = (
+ await asyncio.to_thread(
+ persist_inline_image_refs, [m], self.image_media_store
+ )
+ )[0]
messages.append(Message.model_validate(m))
if request.system_prompt:
messages.insert(
@@ -334,6 +361,15 @@ async def _assemble_request_context_for_provider(
self,
request: ProviderRequest,
) -> dict[str, T.Any]:
+ request = replace(
+ request,
+ image_preparation_options=(
+ request.image_preparation_options
+ or get_image_preparation_options(
+ getattr(self.provider, "provider_settings", {})
+ )
+ ),
+ )
modalities = self.provider.provider_config.get("modalities", None)
if not modalities: # Unconfigured (None or empty list) defaults to support all modalities for backward compatibility
return await request.assemble_context()
@@ -497,12 +533,41 @@ async def _await_or_stop(
abort_task.cancel()
await asyncio.gather(abort_task, return_exceptions=True)
+ async def _prepare_provider_contexts(
+ self, contexts: list[Message] | list[dict[str, T.Any]]
+ ) -> list[Message] | list[dict[str, T.Any]]:
+ """Prepare a request-local image view for normal and tool-requery calls.
+
+ Args:
+ contexts: Selected history and any request-local instructions.
+
+ Returns:
+ Provider-compatible messages without changing persisted history.
+
+ Raises:
+ ImagePayloadTooLargeError: An image exceeds the encoded-byte limit.
+ MemoryError: Image materialization exhausts available memory.
+ """
+ selected_contexts = self._sanitize_contexts_for_provider(contexts)
+ limit = get_image_encoded_byte_limit(
+ getattr(self.provider, "provider_settings", {})
+ )
+ validate_context_image_bytes(selected_contexts, limit)
+ return await materialize_image_media_refs(
+ selected_contexts,
+ self.image_media_store
+ or ImageMediaStore(Path(get_astrbot_data_path()) / "media"),
+ )
+
async def _iter_llm_responses(
self, *, include_model: bool = True
) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
+ provider_contexts = await self._prepare_provider_contexts(
+ self.run_context.messages
+ )
payload = {
- "contexts": self._sanitize_contexts_for_provider(self.run_context.messages),
+ "contexts": provider_contexts,
"func_tool": self._func_tool_for_provider(),
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
@@ -622,7 +687,22 @@ async def _iter_llm_responses_with_fallback(
raise
if self._is_stop_requested():
return
+ except (
+ MemoryError,
+ ImagePayloadTooLargeError,
+ ProviderRequestTooLargeError,
+ ):
+ raise
except Exception as exc: # noqa: BLE001
+ response = getattr(exc, "response", None)
+ status_code = getattr(exc, "status_code", None) or getattr(
+ response, "status_code", None
+ )
+ if status_code == 413:
+ raise ProviderRequestTooLargeError(
+ "The provider rejected this request because it is too large "
+ "(HTTP 413). Reduce the active context or image size."
+ ) from exc
last_exception = exc
logger.warning(
"Chat Model %s request error: %s",
@@ -1060,11 +1140,29 @@ async def step(self):
# Build user message with images for LLM to review
image_parts = []
for cached_img in cached_images:
- img_data = tool_image_cache.get_image_base64_by_path(
- cached_img.file_path, cached_img.mime_type
+ img_data = await prepare_image_source(
+ ImagePreparationInput(
+ cached_img.file_path,
+ source_kind=(
+ "cua_screenshot"
+ if cached_img.tool_name == "astrbot_cua_screenshot"
+ else "tool_image"
+ ),
+ ),
+ options=replace(
+ self.req.image_preparation_options
+ or get_image_preparation_options(
+ getattr(self.provider, "provider_settings", {})
+ ),
+ preserve_dimensions=cached_img.tool_name
+ == "astrbot_cua_screenshot",
+ ),
)
if img_data:
- base64_data, mime_type = img_data
+ base64_data, mime_type = (
+ img_data.base64_data,
+ img_data.mime_type,
+ )
image_parts.append(
TextPart(
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
@@ -1079,9 +1177,15 @@ async def step(self):
)
)
if image_parts:
- self.run_context.messages.append(
- Message(role="user", content=image_parts)
- )
+ image_message = Message(role="user", content=image_parts)
+ if self.image_media_store is not None:
+ stored = await asyncio.to_thread(
+ persist_inline_image_refs,
+ [image_message.model_dump()],
+ self.image_media_store,
+ )
+ image_message = Message.model_validate(stored[0])
+ self.run_context.messages.append(image_message)
logger.debug(
f"Appended {len(cached_images)} cached image(s) to context for LLM review"
)
@@ -1349,9 +1453,9 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
)
except Exception as e:
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
+ except (MemoryError, _ToolExecutionInterrupted):
+ raise
except Exception as e:
- if isinstance(e, _ToolExecutionInterrupted):
- raise
logger.warning(traceback.format_exc())
_append_tool_call_result(
func_tool_id,
@@ -1443,7 +1547,7 @@ async def _resolve_tool_exec(
contexts = self._build_tool_requery_context(tool_names)
requery_resp = await self._await_or_stop(
self.provider.text_chat(
- contexts=self._sanitize_contexts_for_provider(contexts),
+ contexts=await self._prepare_provider_contexts(contexts),
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
@@ -1473,7 +1577,7 @@ async def _resolve_tool_exec(
)
repair_resp = await self._await_or_stop(
self.provider.text_chat(
- contexts=self._sanitize_contexts_for_provider(
+ contexts=await self._prepare_provider_contexts(
repair_contexts
),
func_tool=param_subset,
diff --git a/astrbot/core/agent/tool_image_cache.py b/astrbot/core/agent/tool_image_cache.py
index c873e2c58c..a84eddd31f 100644
--- a/astrbot/core/agent/tool_image_cache.py
+++ b/astrbot/core/agent/tool_image_cache.py
@@ -11,6 +11,7 @@
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+from astrbot.core.utils.media_utils import validate_image_input_size
@dataclass
@@ -91,6 +92,7 @@ def save_image(
try:
# Runtime cache cleanup may remove empty subdirectories.
os.makedirs(self._cache_dir, exist_ok=True)
+ validate_image_input_size(base64_data)
image_bytes = base64.b64decode(base64_data)
with open(file_path, "wb") as f:
f.write(image_bytes)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index ce86e2c18e..c74494754e 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -32,6 +32,7 @@
from astrbot.core.computer.booters.local import resolve_windows_shell
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.db import BaseDatabase
+from astrbot.core.exceptions import ProviderRequestTooLargeError
from astrbot.core.message.components import File, Image, Record, Reply, Video
from astrbot.core.persona_error_reply import (
extract_persona_custom_error_message_from_persona,
@@ -108,7 +109,12 @@
)
from astrbot.core.utils.file_extract import extract_file_moonshotai
from astrbot.core.utils.llm_metadata import LLM_METADATAS
-from astrbot.core.utils.media_utils import is_file_uri, is_recoverable_image_error
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ get_image_preparation_options,
+ is_file_uri,
+ is_recoverable_image_error,
+)
from astrbot.core.utils.quoted_message.settings import (
SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS,
)
@@ -726,7 +732,7 @@ async def _ensure_img_caption(
caption = await _request_img_caption(
image_caption_provider,
cfg,
- req.image_urls,
+ list(req.image_urls),
plugin_context,
)
if caption:
@@ -734,6 +740,8 @@ async def _ensure_img_caption(
TextPart(text=f"{caption}")
)
req.image_urls = []
+ except (ImagePayloadTooLargeError, MemoryError, ProviderRequestTooLargeError):
+ raise
except Exception as exc: # noqa: BLE001
logger.error("处理图片描述失败: %s", exc)
req.extra_user_content_parts.append(TextPart(text="[Image Captioning Failed]"))
@@ -888,8 +896,14 @@ async def _process_quote_message(
)
else:
logger.warning("No provider found for image captioning in quote.")
- except Exception as exc:
- logger.error("Quote image captioning failed (%s).", type(exc).__name__)
+ except (
+ ImagePayloadTooLargeError,
+ MemoryError,
+ ProviderRequestTooLargeError,
+ ):
+ raise
+ except BaseException as exc:
+ logger.error("处理引用图片失败: %s", exc)
quoted_content = "\n".join(content_parts)
quoted_text = f"\n{quoted_content}\n"
@@ -1730,6 +1744,11 @@ async def build_main_agent(
_apply_web_search_citation_prompt(event, req)
+ if req.image_preparation_options is None:
+ req.image_preparation_options = get_image_preparation_options(
+ config.provider_settings
+ )
+
reset_coro = agent_runner.reset(
provider=provider,
request=req,
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index 0e6b2fce1a..d2667f6a66 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -16,12 +16,9 @@
from astrbot.core.agent.context.token_counter import EstimateTokenCounter
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolExecResult
-from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.media_utils import (
- IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
- IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
- IMAGE_COMPRESS_DEFAULT_QUALITY,
- _compress_image_sync,
+ ImagePayloadTooLargeError,
+ validate_image_input_size,
)
from .booters.base import ComputerBooter
@@ -309,25 +306,6 @@ def _run() -> dict[str, str | int]:
return await to_thread(_run)
-async def _read_local_image_base64(
- path: str,
- file_descriptor: int | None = None,
-) -> dict[str, str | int]:
- def _run() -> dict[str, str | int]:
- if file_descriptor is None:
- data = Path(path).read_bytes()
- else:
- with os.fdopen(os.dup(file_descriptor), "rb") as file_obj:
- file_obj.seek(0)
- data = file_obj.read()
- return {
- "size_bytes": len(data),
- "base64": base64.b64encode(data).decode("utf-8"),
- }
-
- return await to_thread(_run)
-
-
async def _read_local_file_bytes(
path: str,
file_descriptor: int | None = None,
@@ -343,33 +321,6 @@ def _run() -> bytes:
return await to_thread(_run)
-async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]:
- def _run() -> dict[str, str | int]:
- temp_dir = Path(get_astrbot_temp_path())
- temp_dir.mkdir(parents=True, exist_ok=True)
- compressed_path = Path(
- _compress_image_sync(
- data,
- temp_dir,
- IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
- IMAGE_COMPRESS_DEFAULT_QUALITY,
- IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
- )
- )
- try:
- compressed_bytes = compressed_path.read_bytes()
- finally:
- compressed_path.unlink(missing_ok=True)
-
- return {
- "size_bytes": len(compressed_bytes),
- "base64": base64.b64encode(compressed_bytes).decode("utf-8"),
- "mime_type": "image/jpeg",
- }
-
- return await to_thread(_run)
-
-
def _detect_image_mime(sample: bytes) -> str | None:
if sample.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
@@ -756,33 +707,34 @@ async def read_file_tool_result(
return "Error reading file: binary files are not supported by this tool."
if probe.kind == "image":
+ try:
+ validate_image_input_size(size_bytes)
+ except ImagePayloadTooLargeError as exc:
+ return f"Error reading file: {exc}"
if local_mode:
- image_payload = await _read_local_image_base64(
- path,
- local_file_descriptor,
- )
+ try:
+ raw_bytes = await _read_local_file_bytes(path, local_file_descriptor)
+ except OSError as exc:
+ return f"Error reading file: failed to read image: {exc}"
+ image_base64_data = base64.b64encode(raw_bytes).decode("utf-8")
+ mime_type = probe.mime_type or "image/jpeg"
else:
image_payload = await _exec_python_json(
booter,
_build_image_read_script(path),
action="image read",
)
- raw_base64_data = str(image_payload.get("base64", "") or "")
- if not raw_base64_data:
- return "Error reading file: image payload is empty."
- raw_bytes = base64.b64decode(raw_base64_data)
- compressed_payload = await _compress_image_bytes_to_base64(raw_bytes)
- compressed_base64_data = str(compressed_payload.get("base64", "") or "")
- if not compressed_base64_data:
- return "Error reading file: compressed image payload is empty."
+ raw_base64_data = str(image_payload.get("base64", "") or "")
+ if not raw_base64_data:
+ return "Error reading file: image payload is empty."
+ image_base64_data = raw_base64_data
+ mime_type = probe.mime_type or "image/jpeg"
return mcp.types.CallToolResult(
content=[
mcp.types.ImageContent(
type="image",
- data=compressed_base64_data,
- mimeType=str(
- compressed_payload.get("mime_type", "") or "image/jpeg"
- ),
+ data=image_base64_data,
+ mimeType=mime_type,
)
]
)
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index f8c46c26e4..862af86a72 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -202,6 +202,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict:
"image_compress_options": {
"max_size": 1280,
"quality": 95,
+ "max_encoded_bytes": 4194304,
},
},
"agent_runner": {
@@ -4232,6 +4233,15 @@ def get_local_permission_defaults(system: str | None = None) -> dict:
},
"slider": {"min": 1, "max": 100, "step": 1},
},
+ "provider_settings.image_compress_options.max_encoded_bytes": {
+ "description": "最大编码大小",
+ "type": "int",
+ "hint": "压缩后单张图片的最大 Base64 编码大小,单位为字节。超过限制且无法进一步压缩时会报错。",
+ "condition": {
+ "provider_settings.image_compress_enabled": True,
+ },
+ "slider": {"min": 262144, "max": 16777216, "step": 262144},
+ },
"provider_settings.prompt_prefix": {
"description": "用户提示词",
"type": "string",
diff --git a/astrbot/core/exceptions.py b/astrbot/core/exceptions.py
index 76c71af573..9915ed3c05 100644
--- a/astrbot/core/exceptions.py
+++ b/astrbot/core/exceptions.py
@@ -13,6 +13,10 @@ class EmptyModelOutputError(AstrBotError):
"""Raised when the model response contains no usable assistant output."""
+class ProviderRequestTooLargeError(AstrBotError):
+ """Raised when a provider rejects the serialized request size."""
+
+
class KnowledgeBaseUploadError(AstrBotError):
"""Raised when knowledge base upload fails with a user-facing message."""
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py
index 61e237b8bd..1a70ce2039 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/image_input.py
@@ -25,6 +25,7 @@ async def prepare_request_images(
prepared: dict[str, str | None],
quote_image_ref: str | None = None,
montage_max_size: int | None = None,
+ preserve_bytes: bool = False,
) -> None:
"""Replace current images on a working request and track their owned files.
@@ -38,6 +39,8 @@ async def prepare_request_images(
prepared: Per-request mapping reused after the request hook.
quote_image_ref: Optional input for the dedicated quote caption branch.
montage_max_size: Optional montage-specific limit; defaults to ``max_size``.
+ preserve_bytes: Preserve supported still-image bytes for coordinate-sensitive
+ consumers.
"""
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
refs = list(req.image_urls)
@@ -59,6 +62,7 @@ async def prepare_request_images(
output_dir=output_dir,
quality=quality,
montage_max_size=montage_max_size,
+ preserve_bytes=preserve_bytes,
)
if path:
event.track_temporary_local_file(path)
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index 322506e3a8..e9272b7dca 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -294,6 +294,7 @@ async def process(
prepared=prepared,
quote_image_ref=quote_image_ref,
montage_max_size=montage_max_size,
+ preserve_bytes=cua_pixel_mode,
)
await _process_quote_message(
event,
@@ -361,6 +362,7 @@ async def process(
output_dir=output_dir,
prepared=prepared,
montage_max_size=montage_max_size,
+ preserve_bytes=cua_pixel_mode,
)
if cua_pixel_mode:
oversized = []
@@ -538,12 +540,14 @@ async def process(
unregister_active_runner(event.unified_msg_origin, agent_runner)
except Exception as e:
- logger.error(f"Error occurred while processing agent: {e}")
+ logger.error(
+ f"Error occurred while processing agent: {type(e).__name__}: {e}"
+ )
custom_error_message = extract_persona_custom_error_message_from_event(
event
)
error_text = custom_error_message or (
- f"Error occurred while processing agent request: {e}"
+ f"Error occurred while processing agent request: {type(e).__name__}: {e}"
)
await event.send(MessageChain().message(error_text))
finally:
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py
index 93836c1fe8..1cd87f41e4 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py
@@ -36,6 +36,7 @@
)
from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.config_number import coerce_int_config
+from astrbot.core.utils.media_utils import get_image_preparation_options
from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
@@ -289,10 +290,21 @@ async def process(
req = ProviderRequest()
req.session_id = event.unified_msg_origin
req.prompt = event.message_str[len(provider_wake_prefix) :]
+ req.image_preparation_options = get_image_preparation_options(
+ self.conf.get("provider_settings")
+ )
for comp in event.message_obj.message:
if isinstance(comp, Image):
- image_path = await comp.convert_to_base64()
- req.image_urls.append(image_path)
+ image_ref = (
+ getattr(comp, "path", None)
+ or getattr(comp, "url", None)
+ or getattr(comp, "file", None)
+ )
+ if not isinstance(image_ref, str):
+ image_ref = await comp.convert_to_file_path()
+ if not image_ref:
+ raise ValueError("Image attachment has no usable reference")
+ req.image_urls.append(image_ref)
elif isinstance(comp, Record):
audio_path = await comp.convert_to_file_path()
req.audio_urls.append(audio_path)
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index 382932b857..b7ebd95d47 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -24,9 +24,12 @@
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationInput,
+ ImagePreparationOptions,
MediaResolver,
is_recoverable_image_error,
- resolve_image_ref_to_base64_data,
+ prepare_image_source,
)
@@ -118,6 +121,8 @@ class ProviderRequest:
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
model: str | None = None
"""模型名称,为 None 时使用提供商的默认模型"""
+ image_preparation_options: ImagePreparationOptions | None = None
+ """当前请求的图片准备配置,由上层 Agent 配置注入。"""
def __repr__(self) -> str:
return (
@@ -215,14 +220,20 @@ async def assemble_context(self) -> dict:
dumped = (
part if isinstance(part, dict) else part.model_dump_for_context()
)
- # Capture bytes before event cleanup. Extra image paths must also
- # reach providers and persisted history as portable data URIs.
if isinstance(dumped, dict) and dumped.get("type") == "image_url":
image_url = dumped.get("image_url")
url = image_url.get("url") if isinstance(image_url, dict) else None
if isinstance(url, str) and url:
try:
- resolved = await resolve_image_ref_to_base64_data(url)
+ prepared = await prepare_image_source(
+ ImagePreparationInput(
+ url,
+ source_kind="extra_user_content",
+ ),
+ options=self.image_preparation_options,
+ )
+ except (ImagePayloadTooLargeError, MemoryError):
+ raise
except Exception as exc:
if not is_recoverable_image_error(exc):
raise
@@ -232,26 +243,25 @@ async def assemble_context(self) -> dict:
)
image_capture_failed = True
continue
- if resolved is None:
- logger.warning(
- "Image source capture returned no data; skipping image."
- )
- image_capture_failed = True
- continue
dumped = {
**dumped,
"image_url": {
**image_url,
- "url": resolved.to_data_url(),
+ "url": prepared.to_data_url(),
},
}
content_blocks.append(dumped)
- # 3. Read image references without resizing or transcoding.
+ # 3. 图片内容
if self.image_urls:
for image_url in self.image_urls:
try:
- image_data = await resolve_image_ref_to_base64_data(image_url)
+ image_data = await prepare_image_source(
+ ImagePreparationInput(image_url, source_kind="request_image"),
+ options=self.image_preparation_options,
+ )
+ except (ImagePayloadTooLargeError, MemoryError):
+ raise
except Exception as exc:
if not is_recoverable_image_error(exc):
raise
@@ -261,12 +271,6 @@ async def assemble_context(self) -> dict:
)
image_capture_failed = True
continue
- if image_data is None:
- logger.warning(
- "Image source capture returned no data; skipping image."
- )
- image_capture_failed = True
- continue
content_blocks.append(
{
"type": "image_url",
diff --git a/astrbot/core/provider/modalities.py b/astrbot/core/provider/modalities.py
index 66ac74e9b7..b14142760a 100644
--- a/astrbot/core/provider/modalities.py
+++ b/astrbot/core/provider/modalities.py
@@ -91,7 +91,11 @@ def sanitize_contexts_by_modalities(
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"}:
+ if not supports_image and part_type in {
+ "image_url",
+ "image",
+ "image_media_ref",
+ }:
removed_any_multimodal = True
stats.fixed_image_blocks += 1
filtered_parts.append({"type": "text", "text": "[Image]"})
@@ -132,7 +136,7 @@ def _tool_result_placeholder(content: Any) -> str:
part_type = str(part.get("type", "")).lower()
if part_type == "text":
text_parts.append(str(part.get("text", "")))
- elif part_type in {"image_url", "image"}:
+ elif part_type in {"image_url", "image", "image_media_ref"}:
text_parts.append("[Image]")
elif part_type in {"audio_url", "input_audio"}:
text_parts.append("[Audio]")
diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py
index 5a21737b44..03f33867ee 100644
--- a/astrbot/core/provider/sources/anthropic_source.py
+++ b/astrbot/core/provider/sources/anthropic_source.py
@@ -1,6 +1,7 @@
import base64
import json
from collections.abc import AsyncGenerator
+from pathlib import Path
from typing import Any, Literal
import anthropic
@@ -12,12 +13,23 @@
from astrbot import logger
from astrbot.api.provider import Provider
+from astrbot.core.agent.context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
+from astrbot.core.provider.modalities import sanitize_contexts_by_modalities
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
from astrbot.core.utils.media_utils import (
describe_media_ref,
+ get_image_preparation_options,
resolve_media_ref_to_base64_data,
)
from astrbot.core.utils.network_utils import (
@@ -274,11 +286,14 @@ def _prepare_payload(self, messages: list[dict]):
if url.startswith("data:"):
try:
_, base64_data = url.split(",", 1)
- # Detect actual image format from binary data
- image_bytes = base64.b64decode(base64_data)
- media_type = self._detect_image_mime_type(
- image_bytes
+ # Decode only the header-sized prefix. The
+ # complete payload remains the provider's
+ # wire data and must not be copied merely
+ # to detect its MIME type.
+ prefix = base64.b64decode(
+ "".join(base64_data.split())[:64]
)
+ media_type = self._detect_image_mime_type(prefix)
converted_content.append(
{
"type": "image",
@@ -780,6 +795,15 @@ async def text_chat(
) -> LLMResponse:
if contexts is None:
contexts = []
+ contexts, _ = sanitize_contexts_by_modalities(
+ contexts, self.provider_config.get("modalities")
+ )
+ validate_context_image_bytes(
+ contexts, get_image_encoded_byte_limit(self.provider_settings)
+ )
+ contexts = await materialize_image_media_refs(
+ contexts, ImageMediaStore(Path(get_astrbot_data_path()) / "media")
+ )
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
@@ -853,6 +877,15 @@ async def text_chat_stream(
):
if contexts is None:
contexts = []
+ contexts, _ = sanitize_contexts_by_modalities(
+ contexts, self.provider_config.get("modalities")
+ )
+ validate_context_image_bytes(
+ contexts, get_image_encoded_byte_limit(self.provider_settings)
+ )
+ contexts = await materialize_image_media_refs(
+ contexts, ImageMediaStore(Path(get_astrbot_data_path()) / "media")
+ )
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
@@ -927,6 +960,7 @@ async def resolve_image_url(image_url: str) -> dict | None:
image_data = await resolve_media_ref_to_base64_data(
image_url,
media_type="image",
+ image_options=get_image_preparation_options(self.provider_settings),
)
if not image_data:
logger.warning("图片预处理结果为空,将忽略。")
@@ -999,6 +1033,7 @@ async def encode_image_bs64(self, image_url: str) -> tuple[str, str]:
image_url,
media_type="image",
strict=True,
+ image_options=get_image_preparation_options(self.provider_settings),
)
if image_data is None:
raise RuntimeError(
diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py
index 00268b2c0b..54eb484138 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -4,6 +4,8 @@
import logging
import random
from collections.abc import AsyncGenerator
+from dataclasses import replace
+from pathlib import Path
from typing import Literal, cast
import httpx
@@ -14,13 +16,25 @@
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
+from astrbot.core.agent.context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
-from astrbot.core.exceptions import EmptyModelOutputError
+from astrbot.core.exceptions import EmptyModelOutputError, ProviderRequestTooLargeError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
+from astrbot.core.provider.modalities import sanitize_contexts_by_modalities
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
from astrbot.core.utils.media_utils import (
describe_media_ref,
+ detect_image_mime_type,
+ get_image_preparation_options,
resolve_media_ref_to_base64_data,
)
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
@@ -133,6 +147,10 @@ def _init_safety_settings(self) -> None:
async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool:
"""处理API错误,返回是否需要重试"""
+ if getattr(e, "code", None) == 413:
+ raise ProviderRequestTooLargeError(
+ "The provider rejected the request because its serialized size is too large (HTTP 413)."
+ ) from e
if e.message is None:
e.message = ""
@@ -299,6 +317,20 @@ async def _prepare_query_config(
async def _prepare_conversation(self, payloads: dict) -> list[types.Content]:
"""准备 Gemini SDK 的 Content 列表"""
+ payloads = dict(payloads)
+ provider_config = getattr(self, "provider_config", {})
+ provider_settings = getattr(self, "provider_settings", {})
+ messages, _ = sanitize_contexts_by_modalities(
+ payloads.get("messages", []), provider_config.get("modalities")
+ )
+ validate_context_image_bytes(
+ messages, get_image_encoded_byte_limit(provider_settings)
+ )
+ payloads["messages"] = messages
+ payloads["messages"] = await materialize_image_media_refs(
+ payloads.get("messages", []),
+ ImageMediaStore(Path(get_astrbot_data_path()) / "media"),
+ )
def create_text_part(text: str) -> types.Part:
content_a = text if text else " "
@@ -308,10 +340,30 @@ def create_text_part(text: str) -> types.Part:
async def process_image_url(image_url_dict: dict) -> types.Part:
url = image_url_dict["url"]
+ if url.startswith("data:"):
+ header, separator, encoded = url.partition(",")
+ if separator and ";base64" in header.lower():
+ image_bytes = base64.b64decode(encoded)
+ declared_mime = header[5:].split(";", 1)[0].strip()
+ detected_mime = await asyncio.to_thread(
+ detect_image_mime_type,
+ image_bytes,
+ default_mime_type=None,
+ )
+ mime_type = detected_mime or declared_mime
+ if mime_type:
+ return types.Part.from_bytes(
+ data=image_bytes,
+ mime_type=mime_type,
+ )
image_data = await resolve_media_ref_to_base64_data(
url,
media_type="image",
strict=True,
+ image_options=replace(
+ get_image_preparation_options(provider_settings),
+ enabled=False,
+ ),
)
if image_data is None:
raise ValueError(
@@ -1007,6 +1059,7 @@ async def resolve_image_part(image_url: str) -> dict | None:
image_data = await resolve_media_ref_to_base64_data(
image_url,
media_type="image",
+ image_options=get_image_preparation_options(self.provider_settings),
)
if not image_data:
logger.warning("Image preprocessing returned no data; ignoring it.")
@@ -1103,6 +1156,7 @@ async def encode_image_bs64(self, image_url: str) -> str:
image_url,
media_type="image",
strict=True,
+ image_options=get_image_preparation_options(self.provider_settings),
)
if image_data is None:
raise RuntimeError(
diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py
index c5cb9bdb82..76a19040e6 100644
--- a/astrbot/core/provider/sources/openai_responses_source.py
+++ b/astrbot/core/provider/sources/openai_responses_source.py
@@ -2,17 +2,28 @@
import inspect
import json
from collections.abc import AsyncGenerator
+from pathlib import Path
from typing import Any
from openai.types.responses import Response
import astrbot.core.message.components as Comp
from astrbot import logger
+from astrbot.core.agent.context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
+from astrbot.core.provider.modalities import sanitize_contexts_by_modalities
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@@ -259,6 +270,17 @@ async def _prepare_chat_payload(
Returns:
The Responses payload and its chat-format source context.
"""
+ contexts = contexts or []
+ contexts, _ = sanitize_contexts_by_modalities(
+ contexts, self.provider_config.get("modalities")
+ )
+ validate_context_image_bytes(
+ contexts, get_image_encoded_byte_limit(self.provider_settings)
+ )
+ contexts = await materialize_image_media_refs(
+ contexts,
+ ImageMediaStore(Path(get_astrbot_data_path()) / "media"),
+ )
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if prompt is not None:
context_query.append(
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index 15fd6b72f4..3cd2bc9547 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -5,6 +5,8 @@
import random
import re
from collections.abc import AsyncGenerator
+from dataclasses import replace
+from pathlib import Path
from typing import Any, Literal
import httpx
@@ -18,6 +20,10 @@
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
+from astrbot.core.agent.context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
from astrbot.core.agent.message import (
AudioURLPart,
ContentPart,
@@ -26,11 +32,20 @@
TextPart,
)
from astrbot.core.agent.tool import ToolSet
-from astrbot.core.exceptions import EmptyModelOutputError
+from astrbot.core.exceptions import EmptyModelOutputError, ProviderRequestTooLargeError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
+from astrbot.core.provider.modalities import sanitize_contexts_by_modalities
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationOptions,
describe_media_ref,
+ get_image_preparation_options,
resolve_media_ref_to_base64_data,
)
from astrbot.core.utils.network_utils import (
@@ -181,11 +196,14 @@ async def _image_ref_to_data_url(
image_ref: str,
*,
mode: Literal["safe", "strict"] = "safe",
+ options: ImagePreparationOptions | None = None,
) -> str | None:
image_data = await resolve_media_ref_to_base64_data(
image_ref,
media_type="image",
strict=mode == "strict",
+ image_options=options
+ or get_image_preparation_options(self.provider_settings),
)
return image_data.to_data_url() if image_data else None
@@ -194,8 +212,13 @@ async def _resolve_image_part(
image_url: str,
*,
image_detail: str | None = None,
+ options: ImagePreparationOptions | None = None,
) -> dict | None:
- image_data = await self._image_ref_to_data_url(image_url, mode="safe")
+ image_data = await self._image_ref_to_data_url(
+ image_url,
+ mode="safe",
+ options=options,
+ )
if not image_data:
logger.warning("图片预处理结果为空,将忽略。")
return None
@@ -266,7 +289,12 @@ async def _resolve_audio_part(self, audio_ref: str) -> dict | None:
},
}
- async def _transform_content_part(self, part: dict) -> dict:
+ async def _transform_content_part(
+ self,
+ part: dict,
+ *,
+ options: ImagePreparationOptions | None = None,
+ ) -> dict:
if not isinstance(part, dict):
return part
@@ -275,10 +303,20 @@ async def _transform_content_part(self, part: dict) -> dict:
if not url:
return part
+ # ProviderRequest.assemble_context() already materializes current
+ # images into a data URL. Keep that request-local representation
+ # unchanged so the adapter does not decode and encode it again.
+ if url.startswith("data:image/"):
+ return part
+
try:
resolved_part = await self._resolve_image_part(
- url, image_detail=image_detail
+ url,
+ image_detail=image_detail,
+ options=options,
)
+ except (ImagePayloadTooLargeError, MemoryError):
+ raise
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
@@ -298,19 +336,30 @@ async def _transform_content_part(self, part: dict) -> dict:
return part
- async def _materialize_message_image_parts(self, message: dict) -> dict:
+ async def _materialize_message_image_parts(
+ self,
+ message: dict,
+ *,
+ options: ImagePreparationOptions | None = None,
+ ) -> dict:
content = message.get("content")
if not isinstance(content, list):
return {**message}
- new_content = [await self._transform_content_part(part) for part in content]
+ new_content = [
+ await self._transform_content_part(part, options=options)
+ for part in content
+ ]
return {**message, "content": new_content}
async def _materialize_context_image_parts(
self, context_query: list[dict]
) -> list[dict]:
+ options = replace(
+ get_image_preparation_options(self.provider_settings), enabled=False
+ )
return [
- await self._materialize_message_image_parts(message)
+ await self._materialize_message_image_parts(message, options=options)
for message in context_query
]
@@ -371,6 +420,9 @@ def __init__(self, provider_config, provider_settings) -> None:
default_headers=self.custom_headers,
base_url=provider_config.get("api_base") or None,
timeout=self.timeout,
+ # Retry is handled by retry_provider_request(); disable the
+ # SDK built-in retry to avoid stacking request attempts.
+ max_retries=0,
http_client=self._create_http_client(provider_config),
)
else:
@@ -380,6 +432,9 @@ def __init__(self, provider_config, provider_settings) -> None:
base_url=provider_config.get("api_base") or None,
default_headers=self.custom_headers,
timeout=self.timeout,
+ # Retry is handled by retry_provider_request(); disable the
+ # SDK built-in retry to avoid stacking request attempts.
+ max_retries=0,
http_client=self._create_http_client(provider_config),
)
@@ -955,6 +1010,15 @@ async def _prepare_chat_payload(
"""准备聊天所需的有效载荷和上下文"""
if contexts is None:
contexts = []
+ contexts, _ = sanitize_contexts_by_modalities(
+ contexts, self.provider_config.get("modalities")
+ )
+ validate_context_image_bytes(
+ contexts, get_image_encoded_byte_limit(self.provider_settings)
+ )
+ contexts = await materialize_image_media_refs(
+ contexts, ImageMediaStore(Path(get_astrbot_data_path()) / "media")
+ )
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
@@ -1075,6 +1139,16 @@ async def _handle_api_error(
image_fallback_used: bool = False,
) -> tuple:
"""处理API错误并尝试恢复"""
+ if isinstance(e, MemoryError):
+ raise e
+ status_code = getattr(e, "status_code", None)
+ response = getattr(e, "response", None)
+ if status_code is None and response is not None:
+ status_code = getattr(response, "status_code", None)
+ if status_code == 413:
+ raise ProviderRequestTooLargeError(
+ "The provider rejected the request because its serialized size is too large (HTTP 413)."
+ ) from e
if "429" in str(e):
logger.warning(
f"API 调用过于频繁,尝试使用其他 Key 重试。当前 Key: {chosen_key[:12]}",
diff --git a/astrbot/core/provider/sources/request_retry.py b/astrbot/core/provider/sources/request_retry.py
index 14dd57d7ab..3123ca2d18 100644
--- a/astrbot/core/provider/sources/request_retry.py
+++ b/astrbot/core/provider/sources/request_retry.py
@@ -11,6 +11,7 @@
)
from astrbot import logger
+from astrbot.core.exceptions import ProviderRequestTooLargeError
from astrbot.core.utils.config_number import coerce_int_config
from astrbot.core.utils.network_utils import is_connection_error
@@ -42,6 +43,8 @@ def _is_retryable_provider_request_error(
*,
retry_rate_limits: bool,
) -> bool:
+ if isinstance(error, (MemoryError, ProviderRequestTooLargeError)):
+ return False
if is_connection_error(error):
return True
@@ -123,7 +126,16 @@ async def retry_provider_request(
async for attempt in retrying:
with attempt:
- return await request_factory()
+ try:
+ return await request_factory()
+ except (MemoryError, ProviderRequestTooLargeError):
+ raise
+ except Exception as error:
+ if _get_status_code(error) == 413:
+ raise ProviderRequestTooLargeError(
+ "The provider rejected the request because its serialized size is too large (HTTP 413)."
+ ) from error
+ raise
raise RuntimeError("Provider request retry loop exited unexpectedly.")
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 906a2f0d8a..11527d1469 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -438,6 +438,8 @@ async def call(
)
except PermissionError as exc:
return f"Error: {exc}"
+ except MemoryError:
+ raise
except Exception as exc:
logger.error(f"Error reading file: {exc}")
return f"Error reading file: {exc}"
diff --git a/astrbot/core/utils/image_media_store.py b/astrbot/core/utils/image_media_store.py
new file mode 100644
index 0000000000..e33c4ac46d
--- /dev/null
+++ b/astrbot/core/utils/image_media_store.py
@@ -0,0 +1,373 @@
+"""Durable, content-addressed storage for conversation image bytes."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import json
+import os
+import tempfile
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+from PIL import Image
+
+from astrbot.core.utils.media_utils import validate_image_input_size
+
+
+@dataclass(frozen=True, slots=True)
+class ImageMediaRef:
+ """A versioned reference persisted in a conversation message.
+
+ Args:
+ media_id: SHA-256 digest of the exact stored bytes.
+ mime_type: MIME type sent to a provider.
+ width: Pixel width, if the bytes are a readable image.
+ height: Pixel height, if the bytes are a readable image.
+ byte_size: Exact stored byte count.
+ detail: Provider image-detail metadata.
+ """
+
+ media_id: str
+ mime_type: str
+ width: int | None
+ height: int | None
+ byte_size: int
+ detail: str | None = None
+ version: int = 1
+ image_id: str | None = None
+
+ def __post_init__(self) -> None:
+ if (
+ self.version != 1
+ or len(self.media_id) != 64
+ or any(char not in "0123456789abcdef" for char in self.media_id)
+ or self.byte_size < 0
+ or not self.mime_type.startswith("image/")
+ or (self.image_id is not None and not isinstance(self.image_id, str))
+ ):
+ raise ValueError("Invalid durable image reference")
+
+ @property
+ def uri(self) -> str:
+ """Return the internal URI; this URI is never sent to a provider."""
+ return f"astrbot-media:v{self.version}:{self.media_id}"
+
+ def model_dump(self) -> dict[str, object]:
+ """Return a stable JSON-compatible history representation."""
+ return {"type": "image_media_ref", **asdict(self)}
+
+
+class ImageMediaStore:
+ """Store immutable image bytes outside the temporary directory."""
+
+ def __init__(self, root: Path) -> None:
+ """Create a store rooted under the configured data directory.
+
+ Args:
+ root: Dedicated durable media directory, not a client path.
+ """
+ self.root = root
+
+ def put(
+ self,
+ data: bytes,
+ mime_type: str | None = None,
+ detail: str | None = None,
+ image_id: str | None = None,
+ ) -> ImageMediaRef:
+ """Atomically persist bytes and return their deduplicated reference.
+
+ Args:
+ data: Exact prepared image bytes.
+ mime_type: Optional MIME type; Pillow detection is preferred.
+ detail: Provider image-detail metadata.
+
+ Returns:
+ A reference whose object and metadata have both been verified.
+
+ Raises:
+ OSError: The object or metadata cannot be committed atomically.
+ ValueError: The stored bytes are not a readable image.
+ """
+ media_id = hashlib.sha256(data).hexdigest()
+ self.root.mkdir(parents=True, exist_ok=True)
+ if self.root.is_symlink() or not self.root.is_dir():
+ raise OSError("invalid durable media root")
+ object_path = self.root / f"{media_id}.bin"
+ metadata_path = self.root / f"{media_id}.json"
+ detected_mime = "application/octet-stream"
+ width: int | None = None
+ height: int | None = None
+ try:
+ with Image.open(io.BytesIO(data)) as image:
+ width, height = image.size
+ detected_mime = Image.MIME.get(image.format, detected_mime)
+ except MemoryError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise ValueError("media bytes are not a readable image") from exc
+ ref = ImageMediaRef(
+ media_id,
+ mime_type or detected_mime,
+ width,
+ height,
+ len(data),
+ detail,
+ image_id=image_id,
+ )
+ canonical = {
+ "media_id": ref.media_id,
+ "mime_type": detected_mime,
+ "width": ref.width,
+ "height": ref.height,
+ "byte_size": ref.byte_size,
+ "version": ref.version,
+ }
+ if object_path.exists() or metadata_path.exists():
+ # Verify shared bytes first. An interrupted metadata write can then
+ # be completed through the same atomic path as a new object.
+ if object_path.is_symlink() or not object_path.is_file():
+ raise OSError("incomplete durable media object")
+ if hashlib.sha256(object_path.read_bytes()).hexdigest() != media_id:
+ raise OSError("media hash verification failed")
+ if metadata_path.is_symlink():
+ raise OSError("incomplete durable media object")
+ if metadata_path.exists():
+ if not metadata_path.is_file():
+ raise OSError("incomplete durable media object")
+ try:
+ persisted = json.loads(metadata_path.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ raise OSError("durable media metadata verification failed") from exc
+ if persisted != canonical:
+ raise OSError("durable media metadata verification failed")
+ self.read(ref, {media_id})
+ return ref
+ temporary_paths: list[Path] = []
+ try:
+ with tempfile.NamedTemporaryFile(dir=self.root, delete=False) as output:
+ object_tmp = Path(output.name)
+ temporary_paths.append(object_tmp)
+ output.write(data)
+ output.flush()
+ os.fsync(output.fileno())
+ try:
+ os.link(temporary_paths[0], object_path)
+ except FileExistsError:
+ pass
+ if hashlib.sha256(object_path.read_bytes()).hexdigest() != media_id:
+ raise OSError("media hash verification failed")
+ with tempfile.NamedTemporaryFile(
+ dir=self.root,
+ prefix=f"{media_id}.",
+ suffix=".json",
+ mode="w",
+ delete=False,
+ ) as metadata_file:
+ metadata_path_tmp = Path(metadata_file.name)
+ temporary_paths.append(metadata_path_tmp)
+ metadata_file.write(json.dumps(canonical, sort_keys=True) + "\n")
+ metadata_file.flush()
+ os.fsync(metadata_file.fileno())
+ try:
+ os.link(metadata_path_tmp, metadata_path)
+ except FileExistsError:
+ pass
+ if json.loads(metadata_path.read_text()) != canonical:
+ raise OSError("durable media metadata verification failed")
+ finally:
+ for path in temporary_paths:
+ path.unlink(missing_ok=True)
+ return ref
+
+ def read(self, ref: ImageMediaRef, allowed_media_ids: set[str]) -> bytes:
+ """Read a reference only when the caller already proved access.
+
+ Args:
+ ref: Persisted reference, not a client-supplied filesystem path.
+ allowed_media_ids: IDs belonging to the authorized conversation.
+
+ Returns:
+ The exact bytes committed for the reference.
+
+ Raises:
+ PermissionError: The reference is outside the authorized history.
+ FileNotFoundError: The durable object is missing.
+ OSError: The object hash no longer matches its reference.
+ """
+ if ref.media_id not in allowed_media_ids:
+ raise PermissionError("media reference is not authorized")
+ if self.root.is_symlink() or not self.root.is_dir():
+ raise OSError("invalid durable media root")
+ path = self.root / f"{ref.media_id}.bin"
+ if path.is_symlink():
+ raise OSError("invalid durable media object")
+ if not path.exists():
+ raise FileNotFoundError("durable media object is unavailable")
+ if not path.is_file():
+ raise OSError("invalid durable media object")
+ metadata_path = self.root / f"{ref.media_id}.json"
+ if metadata_path.is_symlink() or not metadata_path.exists():
+ raise OSError("incomplete durable media object")
+ try:
+ metadata = json.loads(metadata_path.read_text())
+ metadata.pop("type", None)
+ persisted_ref = ImageMediaRef(**metadata)
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
+ raise OSError("durable media metadata verification failed") from exc
+ if (
+ persisted_ref.media_id,
+ persisted_ref.width,
+ persisted_ref.height,
+ persisted_ref.byte_size,
+ persisted_ref.version,
+ ) != (
+ ref.media_id,
+ ref.width,
+ ref.height,
+ ref.byte_size,
+ ref.version,
+ ):
+ raise OSError("durable media metadata verification failed")
+ data = path.read_bytes()
+ if (
+ len(data) != ref.byte_size
+ or hashlib.sha256(data).hexdigest() != ref.media_id
+ ):
+ raise OSError("media hash verification failed")
+ return data
+
+
+def persist_inline_image_refs(
+ history: list[dict],
+ store: ImageMediaStore,
+) -> list[dict]:
+ """Replace newly created data URIs with durable references before saving.
+
+ Args:
+ history: Serialized messages about to be persisted.
+ store: Durable store for this application data root.
+
+ Returns:
+ A new history list. Non-inline URLs remain unchanged for compatibility.
+ """
+ import copy
+
+ for message in history:
+ parts = message.get("content") if isinstance(message, dict) else None
+ if not isinstance(parts, list):
+ continue
+ for part in parts:
+ if not isinstance(part, dict) or part.get("type") != "image_url":
+ continue
+ if part.get("_no_save"):
+ continue
+ image_url = part.get("image_url")
+ url = image_url.get("url") if isinstance(image_url, dict) else None
+ if isinstance(url, str) and url.startswith("data:image/"):
+ validate_image_input_size(url)
+
+ result = copy.deepcopy(history)
+ for message in result:
+ parts = message.get("content") if isinstance(message, dict) else None
+ if not isinstance(parts, list):
+ continue
+ for index, part in enumerate(parts):
+ if not isinstance(part, dict) or part.get("type") != "image_url":
+ continue
+ if part.get("_no_save"):
+ continue
+ image_url = part.get("image_url")
+ url = image_url.get("url") if isinstance(image_url, dict) else None
+ if not isinstance(url, str) or not url.startswith("data:image/"):
+ continue
+ header, payload = url.split(",", 1)
+ mime_type = header[5:].split(";", 1)[0]
+ data = base64.b64decode(payload, validate=True)
+ ref = store.put(
+ data,
+ mime_type,
+ image_url.get("detail") if isinstance(image_url, dict) else None,
+ image_url.get("id") if isinstance(image_url, dict) else None,
+ )
+ parts[index] = ref.model_dump()
+ return result
+
+
+async def materialize_image_media_refs(
+ contexts: list,
+ store: ImageMediaStore,
+ *,
+ strict: bool = False,
+) -> list:
+ """Resolve only the selected references into a request-local view.
+
+ Args:
+ contexts: Messages selected by the context manager.
+ store: Store for the application's configured data root.
+ strict: Fail on missing media when preparing a rollback export.
+
+ Returns:
+ Request-local messages with images, or the original list if no refs exist.
+
+ Raises:
+ ValueError: A reference has invalid metadata.
+ MemoryError: Image materialization exhausts process resources.
+ """
+ import asyncio
+
+ from astrbot import logger
+ from astrbot.core.agent.message import ContentPart, Message
+
+ output = []
+ for message in contexts:
+ serialized = message.model_dump() if isinstance(message, Message) else message
+ parts = serialized.get("content")
+ if not isinstance(parts, list) or not any(
+ isinstance(part, dict) and part.get("type") == "image_media_ref"
+ for part in parts
+ ):
+ output.append(message)
+ continue
+ resolved = []
+ for part in parts:
+ if not isinstance(part, dict) or part.get("type") != "image_media_ref":
+ resolved.append(part)
+ continue
+ try:
+ ref = ImageMediaRef(
+ part["media_id"],
+ part["mime_type"],
+ part.get("width"),
+ part.get("height"),
+ part["byte_size"],
+ part.get("detail"),
+ part.get("version", 1),
+ part.get("image_id"),
+ )
+ payload = await asyncio.to_thread(store.read, ref, {ref.media_id})
+ image_url = {
+ "url": f"data:{ref.mime_type};base64,{base64.b64encode(payload).decode('ascii')}",
+ }
+ del payload
+ if ref.detail is not None:
+ image_url["detail"] = ref.detail
+ if ref.image_id is not None:
+ image_url["id"] = ref.image_id
+ resolved.append({"type": "image_url", "image_url": image_url})
+ except (KeyError, TypeError, ValueError, OSError):
+ if strict:
+ raise
+ logger.warning("A selected conversation image is unavailable")
+ resolved.append({"type": "text", "text": "[Image unavailable]"})
+ if isinstance(message, Message):
+ provider_message = message.model_copy()
+ provider_message.content = [
+ ContentPart.model_validate(part) for part in resolved
+ ]
+ else:
+ provider_message = {**message, "content": resolved}
+ output.append(provider_message)
+ return output
diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py
index ff552293d5..cfbd43841a 100644
--- a/astrbot/core/utils/media_utils.py
+++ b/astrbot/core/utils/media_utils.py
@@ -7,6 +7,7 @@
import asyncio
import base64
import binascii
+import ctypes
import errno
import hashlib
import io
@@ -27,7 +28,7 @@
from aiohttp import ClientError
from PIL import Image as PILImage
-from PIL import ImageOps
+from PIL import ImageOps, UnidentifiedImageError
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -42,6 +43,472 @@
IMAGE_COMPRESS_DEFAULT_QUALITY = 95
IMAGE_COMPRESS_DEFAULT_OPTIMIZE = True
IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB = 1.0
+# Image inputs larger than this are rejected before decoding whenever their
+# encoded size is known.
+MODEL_IMAGE_MAX_INPUT_BYTES = 32 * 1024 * 1024
+IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES = int(
+ IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024
+)
+IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES = 4 * 1024 * 1024
+
+_WEBP_PRESERVE = object()
+_WEBP_DECODER = None
+_WEBP_DECODER_UNAVAILABLE = False
+_WEBP_ADVANCED_DECODER_AVAILABLE = False
+_WEBP_DECODER_ABI_VERSION = 0x0210
+
+
+class _WebPRgbaBuffer(ctypes.Structure):
+ """ctypes view of libwebp's externally-owned packed pixel buffer."""
+
+ _fields_ = [
+ ("rgba", ctypes.POINTER(ctypes.c_ubyte)),
+ ("stride", ctypes.c_int),
+ ("size", ctypes.c_size_t),
+ ]
+
+
+class _WebPYuvaBuffer(ctypes.Structure):
+ """ctypes view of libwebp's YUVA buffer union arm."""
+
+ _fields_ = [
+ ("y", ctypes.POINTER(ctypes.c_ubyte)),
+ ("u", ctypes.POINTER(ctypes.c_ubyte)),
+ ("v", ctypes.POINTER(ctypes.c_ubyte)),
+ ("a", ctypes.POINTER(ctypes.c_ubyte)),
+ ("y_stride", ctypes.c_int),
+ ("u_stride", ctypes.c_int),
+ ("v_stride", ctypes.c_int),
+ ("a_stride", ctypes.c_int),
+ ("y_size", ctypes.c_size_t),
+ ("u_size", ctypes.c_size_t),
+ ("v_size", ctypes.c_size_t),
+ ("a_size", ctypes.c_size_t),
+ ]
+
+
+class _WebPBufferUnion(ctypes.Union):
+ """ctypes view of libwebp's decoded buffer union."""
+
+ _fields_ = [
+ ("rgba", _WebPRgbaBuffer),
+ ("yuva", _WebPYuvaBuffer),
+ ]
+
+
+class _WebPDecBuffer(ctypes.Structure):
+ """ctypes view of libwebp's decoder output descriptor."""
+
+ _fields_ = [
+ ("colorspace", ctypes.c_int),
+ ("width", ctypes.c_int),
+ ("height", ctypes.c_int),
+ ("is_external_memory", ctypes.c_int),
+ ("u", _WebPBufferUnion),
+ ("pad", ctypes.c_uint32 * 4),
+ ("private_memory", ctypes.POINTER(ctypes.c_ubyte)),
+ ]
+
+
+class _WebPBitstreamFeatures(ctypes.Structure):
+ """ctypes view of libwebp's bitstream feature structure."""
+
+ _fields_ = [
+ ("width", ctypes.c_int),
+ ("height", ctypes.c_int),
+ ("has_alpha", ctypes.c_int),
+ ("has_animation", ctypes.c_int),
+ ("format", ctypes.c_int),
+ ("pad", ctypes.c_uint32 * 5),
+ ]
+
+
+class _WebPDecoderOptions(ctypes.Structure):
+ """ctypes view of libwebp's decoder options structure."""
+
+ _fields_ = [
+ (name, ctypes.c_int)
+ for name in (
+ "bypass_filtering",
+ "no_fancy_upsampling",
+ "use_cropping",
+ "crop_left",
+ "crop_top",
+ "crop_width",
+ "crop_height",
+ "use_scaling",
+ "scaled_width",
+ "scaled_height",
+ "use_threads",
+ "dithering_strength",
+ "flip",
+ "alpha_dithering_strength",
+ )
+ ] + [("pad", ctypes.c_uint32 * 5)]
+
+
+class _WebPDecoderConfig(ctypes.Structure):
+ """ctypes view of libwebp's advanced decoder configuration."""
+
+ _fields_ = [
+ ("input", _WebPBitstreamFeatures),
+ ("output", _WebPDecBuffer),
+ ("options", _WebPDecoderOptions),
+ ]
+
+
+class ImagePayloadTooLargeError(ValueError):
+ """Raised when an encoded image cannot fit the preparation budget."""
+
+
+def _get_webp_decoder():
+ """Load the decoder already shipped with Pillow when it exposes C APIs.
+
+ Returns:
+ A configured ctypes library, or ``None`` when the Pillow build does
+ not expose the decoder symbols.
+ """
+ global _WEBP_ADVANCED_DECODER_AVAILABLE
+ global _WEBP_DECODER, _WEBP_DECODER_UNAVAILABLE
+ if _WEBP_DECODER_UNAVAILABLE:
+ return None
+ if _WEBP_DECODER is not None:
+ return _WEBP_DECODER
+ try:
+ from PIL import _webp
+
+ decoder = ctypes.CDLL(_webp.__file__)
+ get_info = decoder.WebPGetInfo
+ decode_rgb = decoder.WebPDecodeRGBInto
+ decode_rgba = decoder.WebPDecodeRGBAInto
+ get_info.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_size_t,
+ ctypes.POINTER(ctypes.c_int),
+ ctypes.POINTER(ctypes.c_int),
+ ]
+ get_info.restype = ctypes.c_int
+ for decode in (decode_rgb, decode_rgba):
+ decode.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_size_t,
+ ctypes.c_void_p,
+ ctypes.c_size_t,
+ ctypes.c_int,
+ ]
+ decode.restype = ctypes.c_void_p
+ except (AttributeError, ImportError, OSError):
+ _WEBP_DECODER_UNAVAILABLE = True
+ return None
+ try:
+ init_config = decoder.WebPInitDecoderConfigInternal
+ decode = decoder.WebPDecode
+ free_buffer = decoder.WebPFreeDecBuffer
+ init_config.argtypes = [
+ ctypes.POINTER(_WebPDecoderConfig),
+ ctypes.c_int,
+ ]
+ init_config.restype = ctypes.c_int
+ decode.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_size_t,
+ ctypes.POINTER(_WebPDecoderConfig),
+ ]
+ decode.restype = ctypes.c_int
+ free_buffer.argtypes = [ctypes.POINTER(_WebPDecBuffer)]
+ free_buffer.restype = None
+ except AttributeError:
+ _WEBP_ADVANCED_DECODER_AVAILABLE = False
+ else:
+ _WEBP_ADVANCED_DECODER_AVAILABLE = True
+ _WEBP_DECODER = decoder
+ return decoder
+
+
+def _webp_container_properties(
+ data: bytes,
+) -> tuple[int, int, bool, bool, bool] | None:
+ """Read WebP dimensions and flags without constructing a Pillow decoder.
+
+ Args:
+ data: Complete WebP file bytes.
+
+ Returns:
+ Width, height, alpha flag, animation flag, and EXIF flag.
+ ``None`` when the container needs the optional decoder to expose its
+ dimensions and that decoder is unavailable.
+
+ Raises:
+ ValueError: The RIFF/WebP container is malformed.
+ """
+ if len(data) < 20 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
+ raise ValueError("invalid WebP container")
+ width = height = None
+ has_alpha = False
+ has_animation = False
+ has_exif = False
+ offset = 12
+ while offset + 8 <= len(data):
+ chunk_type = data[offset : offset + 4]
+ chunk_size = int.from_bytes(data[offset + 4 : offset + 8], "little")
+ start = offset + 8
+ end = start + chunk_size
+ if end > len(data):
+ raise ValueError("truncated WebP chunk")
+ chunk = data[start:end]
+ if chunk_type == b"VP8X" and len(chunk) >= 10:
+ has_alpha = bool(chunk[0] & 0x10)
+ has_animation = bool(chunk[0] & 0x02)
+ width = 1 + int.from_bytes(chunk[4:7] + b"\0", "little")
+ height = 1 + int.from_bytes(chunk[7:10] + b"\0", "little")
+ elif chunk_type in {b"ANIM", b"ANMF"}:
+ has_animation = True
+ elif chunk_type == b"ALPH":
+ has_alpha = True
+ elif chunk_type == b"EXIF":
+ has_exif = True
+ elif chunk_type == b"VP8L" and len(chunk) >= 5 and chunk[0] == 0x2F:
+ has_alpha = has_alpha or bool(
+ int.from_bytes(chunk[1:5], "little") & (1 << 28)
+ )
+ offset = end + (chunk_size & 1)
+ if width is None or height is None:
+ decoder = _get_webp_decoder()
+ if decoder is None:
+ return None
+ get_info = decoder.WebPGetInfo
+ width_value, height_value = ctypes.c_int(), ctypes.c_int()
+ if not get_info(
+ ctypes.c_char_p(data),
+ len(data),
+ ctypes.byref(width_value),
+ ctypes.byref(height_value),
+ ):
+ raise ValueError("invalid WebP bitstream")
+ width, height = width_value.value, height_value.value
+ return width, height, has_alpha, has_animation, has_exif
+
+
+def _decode_webp_with_advanced_config(
+ decoder,
+ data: bytes,
+ width: int,
+ height: int,
+ target: tuple[int, int],
+ has_alpha: bool,
+):
+ """Decode a scaled static WebP into one caller-owned pixel buffer.
+
+ Args:
+ decoder: Configured libwebp shared library.
+ data: Complete encoded WebP bytes.
+ width: Original image width.
+ height: Original image height.
+ target: Requested output dimensions.
+ has_alpha: Whether the decoded image needs an alpha channel.
+
+ Returns:
+ A Pillow image when the advanced ABI is available and decoding succeeds;
+ otherwise ``None`` so the caller can use the basic decoder path.
+
+ Raises:
+ MemoryError: The caller-owned output buffer cannot be allocated.
+ """
+ if not _WEBP_ADVANCED_DECODER_AVAILABLE or target == (width, height):
+ return None
+
+ channels = 4 if has_alpha else 3
+ mode = "RGBA" if has_alpha else "RGB"
+ pixels = bytearray(target[0] * target[1] * channels)
+ pixel_buffer = (ctypes.c_ubyte * len(pixels)).from_buffer(pixels)
+ config = _WebPDecoderConfig()
+ if not decoder.WebPInitDecoderConfigInternal(
+ ctypes.byref(config), _WEBP_DECODER_ABI_VERSION
+ ):
+ return None
+ config.output.colorspace = 1 if has_alpha else 0
+ config.output.is_external_memory = 1
+ config.output.u.rgba.rgba = ctypes.cast(
+ pixel_buffer, ctypes.POINTER(ctypes.c_ubyte)
+ )
+ config.output.u.rgba.stride = target[0] * channels
+ config.output.u.rgba.size = len(pixels)
+ config.options.use_scaling = 1
+ config.options.scaled_width, config.options.scaled_height = target
+ try:
+ if (
+ decoder.WebPDecode(ctypes.c_char_p(data), len(data), ctypes.byref(config))
+ != 0
+ ):
+ return None
+ if (config.output.width, config.output.height) != target:
+ return None
+ finally:
+ decoder.WebPFreeDecBuffer(ctypes.byref(config.output))
+ del pixel_buffer
+ image = PILImage.frombuffer(mode, target, pixels, "raw", mode, 0, 1)
+ image.format = "WEBP"
+ return image
+
+
+def _open_static_webp(
+ source: bytes | Path,
+ max_size: int,
+ max_encoded_bytes: int,
+ *,
+ preserve_dimensions: bool = False,
+ preserve_input: bool = True,
+):
+ """Decode static WebP directly into one caller-owned pixel buffer.
+
+ Args:
+ source: Encoded WebP bytes or a local WebP path.
+ max_size: Maximum edge length for the eventual preparation step.
+ max_encoded_bytes: Base64 payload budget.
+ preserve_dimensions: Whether the eventual preparation step must retain
+ the original dimensions.
+ preserve_input: Whether a compliant static image may be returned
+ without re-encoding.
+
+ Returns:
+ A Pillow image backed by one RGB/RGBA buffer, ``_WEBP_PRESERVE`` for a
+ compliant image, or ``None`` to use Pillow's compatibility path.
+
+ Raises:
+ ImagePayloadTooLargeError: An animation already exceeds the budget.
+ ValueError: The WebP container is invalid.
+ MemoryError: The target pixel buffer cannot be allocated.
+ """
+ data = source if isinstance(source, bytes) else source.read_bytes()
+ try:
+ properties = _webp_container_properties(data)
+ except ValueError:
+ return None
+ if properties is None:
+ return None
+ width, height, has_alpha, has_animation, has_exif = properties
+ PILImage._decompression_bomb_check((width, height))
+ encoded_size = 4 * ((len(data) + 2) // 3)
+ if has_animation:
+ if encoded_size > max_encoded_bytes:
+ raise ImagePayloadTooLargeError(
+ f"Animated image exceeds the {max_encoded_bytes}-byte encoding limit"
+ )
+ return _WEBP_PRESERVE
+ if (
+ preserve_input
+ and encoded_size <= max_encoded_bytes
+ and (preserve_dimensions or max(width, height) <= max_size)
+ ):
+ return _WEBP_PRESERVE
+ # The direct route cannot carry EXIF orientation without loading Pillow's
+ # normal WebP plugin. Keep correctness for metadata-bearing images.
+ if has_exif:
+ return None
+ decoder = _get_webp_decoder()
+ if decoder is None:
+ return None
+ target = (width, height)
+ if not preserve_dimensions:
+ if max(width, height) > max_size:
+ scale = max_size / max(width, height)
+ target = (
+ max(1, int(width * scale)),
+ max(1, int(height * scale)),
+ )
+ elif encoded_size > max_encoded_bytes * 2 and max(width, height) >= max_size:
+ target = (
+ max(1, width * 3 // 4),
+ max(1, height * 3 // 4),
+ )
+ scaled = _decode_webp_with_advanced_config(
+ decoder,
+ data,
+ width,
+ height,
+ target,
+ has_alpha,
+ )
+ if scaled is not None:
+ del data
+ return scaled
+
+ channels = 4 if has_alpha else 3
+ pixels = bytearray(width * height * channels)
+ pixel_buffer = (ctypes.c_ubyte * len(pixels)).from_buffer(pixels)
+ decode = decoder.WebPDecodeRGBAInto if has_alpha else decoder.WebPDecodeRGBInto
+ if not decode(
+ ctypes.c_char_p(data),
+ len(data),
+ pixel_buffer,
+ len(pixels),
+ width * channels,
+ ):
+ raise ValueError("invalid WebP bitstream")
+ del pixel_buffer, data
+ mode = "RGBA" if has_alpha else "RGB"
+ image = PILImage.frombuffer(mode, (width, height), pixels, "raw", mode, 0, 1)
+ image.format = "WEBP"
+ return image
+
+
+@dataclass(slots=True)
+class ImagePreparationOptions:
+ """Options for the single provider-facing image preparation boundary."""
+
+ enabled: bool = True
+ max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE
+ quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY
+ optimize: bool = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
+ max_encoded_bytes: int | None = IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES
+ preserve_dimensions: bool = False
+
+
+def get_image_preparation_options(
+ provider_settings: dict | None,
+) -> ImagePreparationOptions:
+ """Build image preparation options from provider settings.
+
+ Args:
+ provider_settings: Provider-level image preparation configuration.
+
+ Returns:
+ Validated image preparation options using the standard defaults.
+ """
+ if not isinstance(provider_settings, dict):
+ return ImagePreparationOptions()
+
+ enabled = provider_settings.get("image_compress_enabled", True)
+ if not isinstance(enabled, bool):
+ enabled = True
+ raw_options = provider_settings.get("image_compress_options", {})
+ options = raw_options if isinstance(raw_options, dict) else {}
+
+ max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE)
+ if not isinstance(max_size, int) or isinstance(max_size, bool):
+ max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE
+
+ quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY)
+ if not isinstance(quality, int) or isinstance(quality, bool):
+ quality = IMAGE_COMPRESS_DEFAULT_QUALITY
+
+ max_encoded_bytes = options.get(
+ "max_encoded_bytes", IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES
+ )
+ if not isinstance(max_encoded_bytes, int) or isinstance(max_encoded_bytes, bool):
+ max_encoded_bytes = IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES
+ optimize = options.get("optimize", IMAGE_COMPRESS_DEFAULT_OPTIMIZE)
+ if not isinstance(optimize, bool):
+ optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
+
+ return ImagePreparationOptions(
+ enabled=enabled,
+ max_size=max(max_size, 1),
+ quality=min(max(quality, 1), 100),
+ optimize=optimize,
+ max_encoded_bytes=max(max_encoded_bytes, 1),
+ )
+
MEDIA_MIME_EXTENSIONS = {
"audio/wav": ".wav",
@@ -120,6 +587,22 @@
"""
+@dataclass(frozen=True, slots=True)
+class ImagePreparationInput:
+ """Describe an image entering the shared preparation boundary.
+
+ Args:
+ value: Image path, URL, data URI, base64 reference, or raw bytes.
+ source_kind: Logical producer used for diagnostics and experiments.
+ cleanup_paths: Temporary paths owned by the caller and released after
+ preparation finishes.
+ """
+
+ value: MediaRefStr | bytes
+ source_kind: str = "unknown"
+ cleanup_paths: tuple[Path, ...] = ()
+
+
@dataclass(slots=True)
class ResolvedMediaData:
"""Base64 media bytes plus the metadata needed by provider payloads.
@@ -133,6 +616,7 @@ class ResolvedMediaData:
base64_data: str
mime_type: str
format: str | None = None
+ byte_size: int | None = None
def to_bytes(self) -> bytes:
"""Decode the base64 payload, accepting missing padding."""
@@ -175,7 +659,7 @@ def read_bytes(self) -> bytes:
def to_base64(self) -> str:
"""Read the resolved local file and return raw base64 data."""
- return base64.b64encode(self.read_bytes()).decode("utf-8")
+ return _encode_file_to_base64(self.path)
def to_data_url(self) -> str:
"""Read the resolved local file and return a data URL."""
@@ -322,6 +806,157 @@ def _decode_base64_payload(
raise ValueError(error_message) from exc
+def _estimate_base64_decoded_size(
+ payload: str,
+ *,
+ start: int = 0,
+ require_valid_chars: bool = False,
+) -> int | None:
+ """Estimate decoded bytes without creating a compact payload copy.
+
+ Args:
+ payload: Base64 text, possibly containing whitespace.
+ start: Index at which the base64 payload begins.
+ require_valid_chars: Whether to reject characters outside standard
+ base64 and whitespace.
+
+ Returns:
+ The estimated decoded byte count, or ``None`` when the text is not a
+ complete standard base64 payload.
+ """
+ encoded_size = 0
+ padding_size = 0
+ for index, char in enumerate(payload):
+ if index < start:
+ continue
+ if char.isspace():
+ continue
+ if require_valid_chars and char not in (
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
+ ):
+ return None
+ encoded_size += 1
+ if char == "=":
+ padding_size += 1
+
+ if encoded_size % 4 == 1:
+ return None
+ return max(0, encoded_size * 3 // 4 - padding_size)
+
+
+def validate_image_input_size(image_source: bytes | str | Path | int) -> int | None:
+ """Reject an image source that is too large to decode safely.
+
+ Args:
+ image_source: Raw bytes, a local path, an image reference, or a known
+ byte count. HTTP(S) references return ``None`` because their size
+ is unknown until download completes.
+
+ Returns:
+ The known encoded byte count, or ``None`` when the source size cannot
+ be determined without reading it.
+
+ Raises:
+ ImagePayloadTooLargeError: The known input exceeds the image input cap.
+ TypeError: The source type is unsupported.
+ OSError: A local file cannot be inspected.
+ """
+ source_size: int | None = None
+ if isinstance(image_source, bool):
+ raise TypeError("Image source size must not be a boolean")
+ if isinstance(image_source, int):
+ source_size = image_source
+ elif isinstance(image_source, bytes):
+ source_size = len(image_source)
+ elif isinstance(image_source, Path):
+ try:
+ source_size = image_source.stat().st_size
+ except FileNotFoundError:
+ return None
+ elif isinstance(image_source, str):
+ if image_source.startswith(("http://", "https://")):
+ return None
+ if image_source.startswith("data:"):
+ comma_index = image_source.find(",")
+ if comma_index < 0:
+ return None
+ header = image_source[:comma_index]
+ header_parts = header[5:].split(";")
+ if any(part.lower() == "base64" for part in header_parts[1:]):
+ source_size = _estimate_base64_decoded_size(
+ image_source,
+ start=comma_index + 1,
+ )
+ elif image_source.startswith("base64://"):
+ source_size = _estimate_base64_decoded_size(
+ image_source,
+ start=len("base64://"),
+ )
+ else:
+ is_uri = is_file_uri(image_source)
+ if is_uri:
+ try:
+ source_size = Path(file_uri_to_path(image_source)).stat().st_size
+ except FileNotFoundError:
+ source_size = None
+ else:
+ try:
+ source_size = Path(image_source).stat().st_size
+ except (FileNotFoundError, OSError, ValueError):
+ source_size = None
+ if source_size is None and not is_uri:
+ source_size = _estimate_base64_decoded_size(
+ image_source,
+ require_valid_chars=True,
+ )
+ else:
+ raise TypeError(f"Unsupported image source type: {type(image_source).__name__}")
+
+ if source_size is None:
+ return None
+ if source_size < 0:
+ raise ValueError("Image source size must not be negative")
+ if source_size > MODEL_IMAGE_MAX_INPUT_BYTES:
+ raise ImagePayloadTooLargeError(
+ "Image input exceeds the "
+ f"{MODEL_IMAGE_MAX_INPUT_BYTES}-byte limit ({source_size} bytes)"
+ )
+ return source_size
+
+
+def _encode_file_to_base64(path: Path) -> str:
+ """Encode a file without retaining a second full raw-image copy.
+
+ Args:
+ path: Local file to read.
+
+ Returns:
+ Base64 text without a data-URI prefix or line breaks.
+
+ Raises:
+ OSError: The file cannot be opened or read.
+ """
+ encoded = io.StringIO()
+ remainder = b""
+ chunk = b""
+ block = b""
+ chunk_size = 1023 * 1024
+ with path.open("rb") as source:
+ while True:
+ chunk = source.read(chunk_size)
+ if not chunk:
+ break
+ block = remainder + chunk if remainder else chunk
+ complete_size = len(block) - len(block) % 3
+ if complete_size:
+ encoded.write(base64.b64encode(block[:complete_size]).decode("ascii"))
+ remainder = block[complete_size:]
+ del chunk, block
+ if remainder:
+ encoded.write(base64.b64encode(remainder).decode("ascii"))
+ return encoded.getvalue()
+
+
def describe_media_ref(media_ref: object | None) -> str:
"""Return a log-safe description of a media reference.
@@ -484,6 +1119,8 @@ async def _materialize_media_ref(
cleanup_paths.append(target_path)
try:
await download_file(media_ref, str(target_path))
+ if media_type == "image":
+ validate_image_input_size(target_path)
except Exception:
_cleanup_paths(cleanup_paths)
raise
@@ -509,9 +1146,13 @@ async def _materialize_media_ref(
if is_file_uri(media_ref):
path = Path(file_uri_to_path(media_ref))
+ if media_type == "image":
+ validate_image_input_size(path)
return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
if media_ref.startswith("data:"):
+ if media_type == "image":
+ validate_image_input_size(media_ref)
mime_type, media_bytes = _parse_base64_data_uri(media_ref)
target_suffix = _extension_from_mime_type(mime_type) or suffix
if media_type == "image" and target_suffix == suffix:
@@ -536,6 +1177,8 @@ async def _materialize_media_ref(
)
if media_ref.startswith("base64://"):
+ if media_type == "image":
+ validate_image_input_size(media_ref)
media_bytes = _decode_base64_payload(
media_ref.removeprefix("base64://"),
error_message="invalid base64 media payload",
@@ -568,8 +1211,12 @@ async def _materialize_media_ref(
except OSError:
pass
if path_exists:
+ if media_type == "image":
+ validate_image_input_size(path)
return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
+ if media_type == "image":
+ validate_image_input_size(media_ref)
compact_media_ref = "".join(media_ref.split())
if compact_media_ref:
try:
@@ -911,29 +1558,89 @@ async def open(
async def resolve_image_ref_to_base64_data(
- image_ref: MediaRefStr,
+ image_ref: MediaRefStr | bytes,
*,
strict: bool = False,
default_mime_type: str | None = "image/jpeg",
+ options: ImagePreparationOptions | None = None,
) -> ResolvedMediaData | None:
- """Resolve an image reference to losslessly encoded base64 data.
+ """Resolve and prepare an image reference for a provider request.
+
+ ``strict=False`` returns ``None`` for invalid images so provider payload
+ assembly can skip bad image refs without failing the whole request. Resource
+ and size-limit errors still propagate because returning the original image
+ would allow an invalid or oversized payload to reach a provider.
- Only materializes the source and detects its MIME type; no
- provider-specific format conversion, frame extraction, or montage is
- performed here. Platform senders and generic request assembly rely on
- this to encode image bytes without transforming their content.
+ Args:
+ image_ref: Local path, URL, data URI, base64 reference, or image bytes.
+ strict: Raise ordinary resolution errors instead of returning ``None``.
+ default_mime_type: MIME fallback for otherwise unidentified images.
+ options: Dimension, encoding, and cleanup policy for image preparation.
+
+ Returns:
+ Prepared provider-ready image data, or ``None`` for a safely skippable
+ invalid image when ``strict`` is false.
- ``strict=False`` returns ``None`` for invalid images so payload
- assembly can skip bad image refs without failing the whole request.
+ Raises:
+ ImagePayloadTooLargeError: The image cannot fit the configured budget.
+ MemoryError: Image preparation exhausts process resources.
+ OSError: The source cannot be read or the prepared output cannot be written.
"""
- return await MediaResolver(
- image_ref,
- media_type="image",
- default_suffix=".bin",
- ).to_base64_data(
- strict=strict,
- default_mime_type=default_mime_type,
- )
+ try:
+ return await prepare_image_source(
+ image_ref,
+ options=options,
+ default_mime_type=default_mime_type,
+ )
+ except (ImagePayloadTooLargeError, MemoryError):
+ raise
+ except (OSError, ValueError):
+ is_legacy_base64 = isinstance(image_ref, str) and image_ref.startswith(
+ "base64://"
+ )
+ if isinstance(image_ref, str) and not is_legacy_base64:
+ is_reference_scheme = image_ref.startswith(
+ ("http://", "https://", "data:")
+ ) or is_file_uri(image_ref)
+ try:
+ path_exists = Path(image_ref).exists()
+ except OSError:
+ path_exists = False
+ if not is_reference_scheme and not path_exists:
+ try:
+ _decode_base64_payload(
+ "".join(image_ref.split()),
+ error_message="invalid bare base64 media payload",
+ validate=True,
+ )
+ except ValueError:
+ pass
+ else:
+ is_legacy_base64 = True
+ if is_legacy_base64:
+ # Preserve the historical fallback for opaque legacy base64 refs,
+ # while still enforcing the configured request budget.
+ legacy = await MediaResolver(
+ image_ref,
+ media_type="image",
+ default_suffix=".bin",
+ ).to_base64_data(
+ strict=strict,
+ default_mime_type=default_mime_type,
+ )
+ if (
+ legacy
+ and options
+ and options.max_encoded_bytes is not None
+ and len(legacy.base64_data) > options.max_encoded_bytes
+ ):
+ raise ImagePayloadTooLargeError(
+ f"Image exceeds the {options.max_encoded_bytes}-byte encoding limit"
+ )
+ return legacy
+ if strict:
+ raise
+ return None
def _image_convert_cache_dir() -> Path:
@@ -1224,7 +1931,11 @@ def _publish_image_cache_atomic(
def _convert_image_bytes_sync(
- source_bytes: bytes, max_size: int, quality: int
+ source_bytes: bytes,
+ max_size: int,
+ quality: int,
+ *,
+ preserve_bytes: bool = False,
) -> bytes:
"""Normalize a validated still image with an optional derived cache.
@@ -1232,16 +1943,23 @@ def _convert_image_bytes_sync(
source_bytes: Encoded source bytes already checked by _inspect_image.
max_size: Longest-edge limit in pixels.
quality: JPEG output quality in the range 1-100.
+ preserve_bytes: Keep a supported, correctly oriented still byte-exact
+ even when it exceeds the normal small-source threshold.
Returns:
Single-frame JPEG or PNG bytes. An oriented JPEG or PNG within the size
- limit is reused unchanged; anything else is re-encoded.
+ and small-source limit is reused unchanged; anything else is re-encoded.
"""
+ validate_image_input_size(source_bytes)
with PILImage.open(io.BytesIO(source_bytes)) as image:
if (
image.format in {"PNG", "JPEG"}
and image.getexif().get(274, 1) == 1
and max(image.size) <= max_size
+ and (
+ preserve_bytes
+ or len(source_bytes) <= IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES
+ )
):
return source_bytes
cache_key = _image_convert_cache_key(
@@ -1367,6 +2085,7 @@ async def prepare_model_image(
output_dir: Path,
quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY,
montage_max_size: int | None = None,
+ preserve_bytes: bool = False,
) -> str | None:
"""Prepare a single local model-ready image for the caller to own until consumption.
@@ -1380,6 +2099,8 @@ async def prepare_model_image(
but montages are never used for coordinates, so callers pass the
configured limit here to keep the 3x3 canvas bounded. Defaults to
``max_size``.
+ preserve_bytes: Preserve supported, correctly oriented still-image bytes
+ for coordinate-sensitive consumers such as CUA.
Returns:
An existing JPEG or PNG path, or None for a recoverable input or write
@@ -1399,7 +2120,11 @@ async def prepare_model_image(
)
else:
converted_bytes = await asyncio.to_thread(
- _convert_image_bytes_sync, image_bytes, max_size, quality
+ _convert_image_bytes_sync,
+ image_bytes,
+ max_size,
+ quality,
+ preserve_bytes=preserve_bytes,
)
# Publish the working file synchronously after encoding, so cancellation
# cannot leave an untracked background write alive after this call.
@@ -1453,10 +2178,12 @@ async def resolve_audio_ref_to_base64_data(
async def resolve_media_ref_to_base64_data(
- media_ref: MediaRefStr,
+ media_ref: MediaRefStr | bytes,
*,
media_type: str,
strict: bool = False,
+ image_options: ImagePreparationOptions | None = None,
+ default_mime_type: str | None = "image/jpeg",
) -> ResolvedMediaData | None:
"""Resolve a media reference to base64 data through one shared entrypoint.
@@ -1465,7 +2192,12 @@ async def resolve_media_ref_to_base64_data(
"""
if media_type == "image":
- return await resolve_image_ref_to_base64_data(media_ref, strict=strict)
+ return await resolve_image_ref_to_base64_data(
+ media_ref,
+ strict=strict,
+ default_mime_type=default_mime_type,
+ options=image_options,
+ )
if media_type == "audio":
return await resolve_audio_ref_to_base64_data(media_ref)
@@ -2009,134 +2741,487 @@ async def extract_video_cover(
raise Exception("ffmpeg not found")
+def _resize_alpha_in_strips(
+ source: PILImage.Image, size: tuple[int, int]
+) -> PILImage.Image:
+ """Resize transparency with bounded premultiplication buffers.
+
+ Pillow premultiplies a complete RGBA image before filtering. Processing
+ overlapping strips keeps those temporary buffers proportional to width,
+ while retaining the Lanczos support around each output strip.
+
+ Args:
+ source: Caller-owned image with an alpha channel.
+ size: Output dimensions.
+
+ Returns:
+ A caller-owned resized image.
+
+ Raises:
+ OSError: Pixel decoding or resizing fails.
+ MemoryError: Output or temporary pixel allocation fails.
+ """
+ output = PILImage.new(source.mode, size)
+ try:
+ ratio = source.height / size[1]
+ halo = math.ceil(3 * max(1, ratio)) + 1
+ # Align strip boundaries with source rows whenever the rational scale
+ # permits it, avoiding floating-point phase changes at those boundaries.
+ alignment = size[1] // math.gcd(source.height, size[1])
+ strip_height = max(alignment, 32 // alignment * alignment)
+ if strip_height > 64:
+ strip_height = 32
+ for top in range(0, size[1], strip_height):
+ bottom = min(top + strip_height, size[1])
+ start = max(0, math.floor(top * ratio) - halo)
+ end = min(source.height, math.ceil(bottom * ratio) + halo)
+ with source.crop((0, start, source.width, end)) as strip:
+ with strip.resize(
+ (size[0], bottom - top),
+ PILImage.Resampling.LANCZOS,
+ box=(0, top * ratio - start, source.width, bottom * ratio - start),
+ ) as resized:
+ output.paste(resized, (0, top))
+ return output
+ except BaseException:
+ output.close()
+ raise
+
+
def _compress_image_sync(
source: bytes | Path,
temp_dir: Path,
max_size: int,
quality: int,
optimize: bool,
+ max_encoded_bytes: int = IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES,
+ *,
+ preserve_dimensions: bool = False,
) -> str | None:
- """Run image compression synchronously via ``asyncio.to_thread``.
+ """Prepare one image without allocating base64 for candidate measurements.
Args:
- source: Encoded image bytes or a local path to open inside the worker.
- temp_dir: Directory where the compressed image should be written.
- max_size: Longest edge of the compressed image in pixels.
- quality: JPEG output quality in the range 1-100.
- optimize: Whether Pillow should optimize the saved image.
+ source: Encoded bytes or a local file opened inside the worker.
+ temp_dir: Directory for the caller-owned prepared file.
+ max_size: Maximum edge length when resizing is allowed.
+ quality: Initial JPEG quality, between 1 and 100.
+ optimize: Whether to optimize the encoder output.
+ max_encoded_bytes: Maximum base64 payload size, excluding its URI header.
+ preserve_dimensions: Preserve screenshot coordinates, even over max_size.
Returns:
- The compressed image path, or ``None`` when the image should be kept as-is.
+ A caller-owned output path, or None to preserve the original bytes.
+
+ Raises:
+ ImagePayloadTooLargeError: No candidate meets the encoded-byte budget.
+ ValueError: A size or quality option is invalid.
+ OSError: Reading, decoding or writing the image fails.
"""
+ if max_size < 1 or max_encoded_bytes < 1 or not 1 <= quality <= 100:
+ raise ValueError("Image dimensions, byte budget and quality must be positive")
+ if isinstance(source, bytes):
+ source_bytes = len(source)
+ else:
+ source_bytes = validate_image_input_size(source)
+ if source_bytes is None:
+ source_bytes = source.stat().st_size
+ encoded_size = 4 * ((source_bytes + 2) // 3)
+ direct_webp = None
+ if isinstance(source, bytes):
+ is_webp = source[:4] == b"RIFF" and source[8:12] == b"WEBP"
+ else:
+ with source.open("rb") as source_stream:
+ header = source_stream.read(12)
+ is_webp = (
+ len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP"
+ )
+ if is_webp:
+ direct_webp = _open_static_webp(
+ source,
+ max_size,
+ max_encoded_bytes,
+ preserve_dimensions=preserve_dimensions,
+ preserve_input=source_bytes <= IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES,
+ )
+ if direct_webp is _WEBP_PRESERVE:
+ return None
fp = io.BytesIO(source) if isinstance(source, bytes) else source
- with PILImage.open(fp) as opened_img:
- converted_img: PILImage.Image | None = None
+ opened = direct_webp if direct_webp is not None else PILImage.open(fp)
+ with opened:
+ animated = getattr(opened, "n_frames", 1) > 1
+ # This baseline preserves animation; do not flatten it to fit a budget.
+ if animated:
+ if encoded_size > max_encoded_bytes:
+ raise ImagePayloadTooLargeError(
+ f"Animated image exceeds the {max_encoded_bytes}-byte encoding limit"
+ )
+ return None
+ if (
+ source_bytes <= IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES
+ and encoded_size <= max_encoded_bytes
+ and (preserve_dimensions or max(opened.size) <= max_size)
+ ):
+ return None
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ path: Path | None = None
+ best_size: int | None = None
+ success = False
+ # Resize before EXIF handling loads the pixels. JPEG thumbnailing can
+ # then use decoder-level downsampling instead of a full-size RGB buffer.
+ # A square bound is unchanged by EXIF rotations and reflections.
+ if (
+ opened.format == "JPEG"
+ and not preserve_dimensions
+ and max(opened.size) > max_size
+ ):
+ opened.thumbnail(
+ (max_size, max_size), PILImage.Resampling.LANCZOS, reducing_gap=1.0
+ )
+ # In-place orientation avoids holding a second full oriented image.
+ ImageOps.exif_transpose(opened, in_place=True)
+ has_alpha = opened.mode in {"RGBA", "LA"} or (
+ opened.mode == "P" and "transparency" in opened.info
+ )
+ target_mode = "RGBA" if has_alpha else "RGB"
+ converted = opened.convert(target_mode) if opened.mode != target_mode else None
+ working = converted if converted is not None else opened
try:
- if (
- getattr(opened_img, "is_animated", False)
- or getattr(opened_img, "n_frames", 1) > 1
- ):
- return None
-
- working_img = opened_img
- image_has_alpha = opened_img.mode in {"RGBA", "LA"} or (
- opened_img.mode == "P" and "transparency" in opened_img.info
+ if not preserve_dimensions and max(working.size) > max_size:
+ working.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
+ qualities = (
+ [quality]
+ if has_alpha
+ else sorted(
+ {
+ quality,
+ *[value for value in (85, 70, 55, 40) if value < quality],
+ },
+ reverse=True,
+ )
)
- output_format = "PNG" if image_has_alpha else "JPEG"
- output_suffix = ".png" if image_has_alpha else ".jpg"
-
- if image_has_alpha and opened_img.mode != "RGBA":
- converted_img = opened_img.convert("RGBA")
- working_img = converted_img
- elif not image_has_alpha and opened_img.mode != "RGB":
- converted_img = opened_img.convert("RGB")
- working_img = converted_img
- assert working_img is not None
-
- if max(working_img.size) > max_size:
- working_img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
-
- save_path = (
- temp_dir / f"compressed_{generate_timestamp_id()}{output_suffix}"
+ source_format = str(opened.format or "").upper()
+ # If the encoded source is already more than twice the budget, a
+ # same-size candidate cannot be a useful memory-saving first step.
+ # Resize once before encoding so the source pixels and encoder
+ # buffers are not resident together at the original dimensions.
+ skip_current_candidate = (
+ not preserve_dimensions
+ and encoded_size > max_encoded_bytes * 2
+ and max(working.size) >= max_size
)
- save_kwargs: dict[str, int | bool] = {"optimize": optimize}
- if output_format == "JPEG":
- save_kwargs["quality"] = quality
- working_img.save(save_path, output_format, **save_kwargs)
- logger.debug(f"Image compressed successfully: {save_path}")
- return str(save_path)
+ while True:
+ if not skip_current_candidate:
+ if has_alpha:
+ formats = [("PNG", ".png", None)]
+ else:
+ formats = (
+ [("PNG", ".png", None)] if source_format == "PNG" else []
+ ) + [
+ ("JPEG", ".jpg", candidate_quality)
+ for candidate_quality in qualities
+ ]
+ for output_format, suffix, candidate_quality in formats:
+ candidate_path: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=temp_dir,
+ prefix="compressed_",
+ suffix=suffix,
+ delete=False,
+ ) as output:
+ candidate_path = Path(output.name)
+ kwargs = {"optimize": optimize}
+ if candidate_quality is not None:
+ kwargs["quality"] = candidate_quality
+ working.save(candidate_path, output_format, **kwargs)
+ candidate_size = candidate_path.stat().st_size
+ encoded_size = 4 * ((candidate_size + 2) // 3)
+ if encoded_size <= max_encoded_bytes and (
+ best_size is None or candidate_size < best_size
+ ):
+ if path is not None:
+ path.unlink(missing_ok=True)
+ path = candidate_path
+ best_size = candidate_size
+ candidate_path = None
+ finally:
+ if candidate_path is not None:
+ candidate_path.unlink(missing_ok=True)
+ # Compare a lossless PNG with the highest fitting JPEG
+ # quality; do not lower quality merely to minimize bytes.
+ if output_format == "JPEG" and path is not None:
+ break
+ skip_current_candidate = False
+ if path is not None:
+ success = True
+ return str(path)
+ if preserve_dimensions or working.size == (1, 1):
+ raise ImagePayloadTooLargeError(
+ f"Image cannot fit the {max_encoded_bytes}-byte encoding limit"
+ )
+ # One current candidate, replaced on disk; never retain all encodings.
+ target = (
+ max(1, working.width * 3 // 4),
+ max(1, working.height * 3 // 4),
+ )
+ if has_alpha:
+ # Match thumbnail's aspect-ratio rounding. Rounding each
+ # edge independently can stretch narrow or odd-sized images.
+ width, height = target
+ aspect = working.width / working.height
+ if width / height >= aspect:
+ width = max(
+ min(
+ math.floor(height * aspect),
+ math.ceil(height * aspect),
+ key=lambda value: abs(aspect - value / height),
+ ),
+ 1,
+ )
+ else:
+ height = max(
+ min(
+ math.floor(width / aspect),
+ math.ceil(width / aspect),
+ key=lambda value: (
+ 0 if value == 0 else abs(aspect - width / value)
+ ),
+ ),
+ 1,
+ )
+ target = (width, height)
+ resized = _resize_alpha_in_strips(working, target)
+ working.close()
+ working = converted = resized
+ else:
+ working.thumbnail(target, PILImage.Resampling.LANCZOS)
finally:
- if converted_img is not None:
- converted_img.close()
+ if converted is not None:
+ converted.close()
+ if not success and path is not None:
+ path.unlink(missing_ok=True)
async def compress_image(
url_or_path: str,
max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY,
+ max_encoded_bytes: int = IMAGE_COMPRESS_DEFAULT_MAX_ENCODED_BYTES,
+ *,
+ optimize: bool = IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
+ preserve_dimensions: bool = False,
) -> str:
- """Compress large user-uploaded images.
+ """Prepare a local image, preserving compliant bytes.
Args:
- url_or_path: Image path or URL.
- max_size: Longest edge of the compressed image in pixels.
- quality: JPEG output quality in the range 1-100.
+ url_or_path: Local path or inline image; remote URLs remain unresolved.
+ max_size: Maximum edge length when resizing is allowed.
+ quality: Initial JPEG quality.
+ max_encoded_bytes: Maximum base64 payload size.
+ preserve_dimensions: Preserve oriented screenshot dimensions.
Returns:
- The compressed image path. Returns the original path if compression
- fails or the source does not need compression.
+ The original reference or a caller-owned prepared file path.
+
+ Raises:
+ ImagePayloadTooLargeError: The image cannot meet the byte limit.
+ OSError: Image decoding or filesystem access fails.
"""
+ if url_or_path.startswith(("http://", "https://")):
+ return url_or_path
max_size = max(int(max_size), 1)
quality = min(max(int(quality), 1), 100)
- optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
- min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024)
- image_source: bytes | Path | None = None
+ max_encoded_bytes = max(int(max_encoded_bytes), 1)
+ image_source: bytes | Path
+ source_size: int
- def _exceeds_max_size(source: bytes | Path) -> bool:
+ def _fits_max_size(source: bytes | Path) -> bool | None:
try:
- fp = io.BytesIO(source) if isinstance(source, bytes) else source
- with PILImage.open(fp) as opened_img:
- return max(opened_img.size) > max_size
- except Exception: # noqa: BLE001
- return False
-
- # Skip compression for remote images and return the original value.
- if url_or_path.startswith("http"):
- return url_or_path
- elif url_or_path.startswith("data:image"):
- _header, encoded = url_or_path.split(",", 1)
- image_source = _decode_base64_payload(
- encoded,
- error_message="invalid image data URI payload",
+ image_file = io.BytesIO(source) if isinstance(source, bytes) else source
+ with PILImage.open(image_file) as opened_image:
+ return max(opened_image.size) <= max_size
+ except MemoryError:
+ raise
+ except Exception:
+ return None
+
+ if url_or_path.startswith("data:image"):
+ validate_image_input_size(url_or_path)
+ _, encoded = url_or_path.split(",", 1)
+ image_source: bytes | Path = _decode_base64_payload(
+ encoded, error_message="invalid image data URI payload"
)
- if len(image_source) < min_file_size_bytes and not _exceeds_max_size(
- image_source
- ):
- return url_or_path
+ source_size = len(image_source)
else:
- local_path = Path(url_or_path)
- if not local_path.exists():
+ image_source = Path(url_or_path)
+ source_size = validate_image_input_size(image_source)
+ if source_size is None:
return url_or_path
- if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
- local_path
- ):
- return url_or_path
- image_source = local_path
- if image_source is None:
+ encoded_size = 4 * ((source_size + 2) // 3)
+ if (
+ source_size < IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES
+ and encoded_size <= max_encoded_bytes
+ and _fits_max_size(image_source)
+ ):
return url_or_path
- temp_dir = Path(get_astrbot_temp_path())
- temp_dir.mkdir(parents=True, exist_ok=True)
-
- # Offload the blocking image processing task to a thread.
- compressed_path = await asyncio.to_thread(
- _compress_image_sync,
- image_source,
- temp_dir,
- max_size,
- quality,
- optimize,
+ worker = asyncio.create_task(
+ asyncio.to_thread(
+ _compress_image_sync,
+ image_source,
+ Path(get_astrbot_temp_path()),
+ max_size,
+ quality,
+ optimize,
+ max_encoded_bytes,
+ preserve_dimensions=preserve_dimensions,
+ )
)
+ try:
+ compressed_path = await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ # Cancellation cannot stop Pillow in a thread. Retain ownership until
+ # the worker finishes, then release its output without deleting inputs.
+ def cleanup_finished(done: asyncio.Task) -> None:
+ try:
+ output = done.result()
+ if output is not None:
+ Path(output).unlink(missing_ok=True)
+ except Exception:
+ logger.warning("Cancelled image preparation cleanup failed")
+
+ worker.add_done_callback(cleanup_finished)
+ raise
return compressed_path or url_or_path
+
+
+async def prepare_image_source(
+ image_ref: MediaRefStr | bytes | ImagePreparationInput,
+ *,
+ options: ImagePreparationOptions | None = None,
+ default_mime_type: str | None = "image/jpeg",
+) -> ResolvedMediaData:
+ """Resolve and prepare any image reference for a provider request.
+
+ Args:
+ image_ref: Local path, HTTP(S) URL, data URI, base64 reference, bare
+ base64 payload, or an ``ImagePreparationInput`` descriptor.
+ options: Optional preparation limits. ``None`` uses the standard budget.
+ default_mime_type: Fallback MIME type for otherwise unidentified images.
+
+ Returns:
+ Provider-ready base64 data and its detected MIME type.
+
+ Raises:
+ ImagePayloadTooLargeError: The image cannot fit the configured budget.
+ OSError: The source cannot be read or decoded.
+ ValueError: The source is not a valid image.
+ """
+ selected = options or ImagePreparationOptions()
+ preparation_input = (
+ image_ref
+ if isinstance(image_ref, ImagePreparationInput)
+ else ImagePreparationInput(image_ref)
+ )
+ source_ref = preparation_input.value
+
+ async def _prepare() -> ResolvedMediaData:
+ owned_source: Path | None = None
+ try:
+ resolved_source = source_ref
+ if isinstance(source_ref, bytes):
+ validate_image_input_size(source_ref)
+ owned_source = _temp_media_path("image", ".bin")
+ await asyncio.to_thread(owned_source.write_bytes, source_ref)
+ resolved_source = str(owned_source)
+ async with MediaResolver(
+ resolved_source, media_type="image", default_suffix=".bin"
+ ).as_path() as resolved:
+ if not selected.enabled:
+ source_size = resolved.path.stat().st_size
+ if (
+ selected.max_encoded_bytes is not None
+ and 4 * ((source_size + 2) // 3) > selected.max_encoded_bytes
+ ):
+ raise ImagePayloadTooLargeError(
+ f"Image exceeds the {selected.max_encoded_bytes}-byte encoding limit"
+ )
+ mime_type = await detect_image_mime_type_async(
+ resolved.path, default_mime_type=None
+ )
+ if not mime_type:
+ raise ValueError(
+ f"Invalid image file: {describe_media_ref(resolved_source)}"
+ )
+ image_size = source_size
+ return ResolvedMediaData(
+ base64_data=await asyncio.to_thread(
+ _encode_file_to_base64, resolved.path
+ ),
+ mime_type=mime_type,
+ byte_size=image_size,
+ )
+ try:
+ prepared_path = await compress_image(
+ str(resolved.path),
+ max_size=selected.max_size,
+ quality=selected.quality,
+ max_encoded_bytes=(
+ selected.max_encoded_bytes
+ if selected.max_encoded_bytes is not None
+ else 2**63 - 1
+ ),
+ optimize=selected.optimize,
+ preserve_dimensions=selected.preserve_dimensions,
+ )
+ except UnidentifiedImageError as exc:
+ raise ValueError(
+ f"Invalid image file: {describe_media_ref(source_ref)}"
+ ) from exc
+ output_path = Path(prepared_path)
+ try:
+ mime_type = await detect_image_mime_type_async(
+ output_path, default_mime_type=None
+ )
+ image_size = output_path.stat().st_size
+ encoded_data = await asyncio.to_thread(
+ _encode_file_to_base64, output_path
+ )
+ finally:
+ if output_path != resolved.path:
+ output_path.unlink(missing_ok=True)
+ if mime_type == "application/octet-stream":
+ mime_type = None
+ if mime_type is None and resolved.mime_type not in {
+ None,
+ "application/octet-stream",
+ }:
+ mime_type = resolved.mime_type
+ if not mime_type:
+ mime_type = default_mime_type
+ if not mime_type:
+ raise ValueError(
+ f"Invalid image file: {describe_media_ref(resolved_source)}"
+ )
+ return ResolvedMediaData(
+ base64_data=encoded_data,
+ mime_type=mime_type,
+ byte_size=image_size,
+ )
+ finally:
+ if owned_source is not None:
+ owned_source.unlink(missing_ok=True)
+ for cleanup_path in preparation_input.cleanup_paths:
+ cleanup_path.unlink(missing_ok=True)
+
+ worker = asyncio.create_task(_prepare())
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ # The worker owns the resolver context until Pillow and file reads exit.
+ worker.add_done_callback(
+ lambda done: done.exception() if not done.cancelled() else None
+ )
+ raise
diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py
index 2538a06b3b..403c263602 100644
--- a/astrbot/dashboard/api/chat.py
+++ b/astrbot/dashboard/api/chat.py
@@ -16,6 +16,8 @@
ChatThreadMessageRequest,
)
from astrbot.dashboard.services.chat_service import (
+ MAX_UPLOAD_FILE_SIZE_BYTES,
+ MAX_UPLOAD_FILE_SIZE_MB,
ChatService,
ChatServiceError,
)
@@ -548,6 +550,11 @@ async def dashboard_post_file(
service: ChatService = Depends(get_service),
):
try:
+ content_length = int(request.headers.get("content-length") or 0)
+ if content_length > MAX_UPLOAD_FILE_SIZE_BYTES:
+ raise ChatServiceError(
+ f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
+ )
upload = await single_upload(request)
if upload is None:
raise ChatServiceError("Missing key: file")
diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py
index f75003d457..06626524b7 100644
--- a/astrbot/dashboard/api/conversations.py
+++ b/astrbot/dashboard/api/conversations.py
@@ -3,7 +3,7 @@
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
-from fastapi.responses import StreamingResponse
+from fastapi.responses import Response, StreamingResponse
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import ApiError, ok
@@ -173,6 +173,21 @@ async def export_conversations(
return await _export_conversations(_model_dict(payload), service)
+@router.get("/conversations/{conversation_id:path}/media/{media_id}")
+async def preview_conversation_media(
+ conversation_id: str,
+ media_id: str,
+ user_id: str = Query(...),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ try:
+ media = await service.get_conversation_media(user_id, conversation_id, media_id)
+ return Response(content=media.data, media_type=media.mime_type)
+ except ConversationServiceError as exc:
+ _raise_conversation_error(exc)
+
+
@router.post("/conversations/batch-delete")
async def batch_delete_conversations(
payload: ConversationBatchDeleteRequest,
diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py
index ca651f6452..b94443b1be 100644
--- a/astrbot/dashboard/services/chat_service.py
+++ b/astrbot/dashboard/services/chat_service.py
@@ -36,6 +36,9 @@
SSE_HEARTBEAT = ": heartbeat\n\n"
CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256
+# Uploaded chat attachments larger than this are rejected.
+MAX_UPLOAD_FILE_SIZE_MB = 100
+MAX_UPLOAD_FILE_SIZE_BYTES = MAX_UPLOAD_FILE_SIZE_MB * 1024 * 1024
WEBCHAT_IMAGE_MIME_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -596,6 +599,10 @@ async def resolve_attachment_file_from_dashboard_query(
return await self.resolve_attachment_file(attachment_id)
async def save_uploaded_file(self, file) -> dict:
+ if (file.content_length or 0) > MAX_UPLOAD_FILE_SIZE_BYTES:
+ raise ChatServiceError(
+ f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
+ )
filename = sanitize_upload_filename(file.filename)
content_type = file.content_type or "application/octet-stream"
@@ -614,6 +621,11 @@ async def save_uploaded_file(self, file) -> dict:
raise ChatServiceError("Invalid filename")
await file.save(str(file_path))
+ if file_path.stat().st_size > MAX_UPLOAD_FILE_SIZE_BYTES:
+ file_path.unlink(missing_ok=True)
+ raise ChatServiceError(
+ f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
+ )
if attach_type == "image":
detected_mime_type = await detect_image_mime_type_async(
file_path,
diff --git a/astrbot/dashboard/services/conversation_service.py b/astrbot/dashboard/services/conversation_service.py
index 4018b2663f..4d6a2994a5 100644
--- a/astrbot/dashboard/services/conversation_service.py
+++ b/astrbot/dashboard/services/conversation_service.py
@@ -1,15 +1,23 @@
from __future__ import annotations
+import asyncio
import json
import traceback
from dataclasses import dataclass
from datetime import datetime
from io import BytesIO
+from pathlib import Path
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.image_media_store import (
+ ImageMediaRef,
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
class ConversationServiceError(Exception):
@@ -23,6 +31,12 @@ class ConversationExport:
mimetype: str = "application/jsonl"
+@dataclass
+class ConversationMedia:
+ data: bytes
+ mime_type: str
+
+
class ConversationService:
def __init__(
self,
@@ -32,6 +46,7 @@ def __init__(
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
+ self.media_store = ImageMediaStore(Path(get_astrbot_data_path()) / "media")
async def list_conversations(
self,
@@ -256,7 +271,9 @@ async def export_conversations(self, data: object) -> ConversationExport:
continue
webchat_titles = await self._get_webchat_titles([conversation])
- content = json.loads(conversation.history)
+ content = await materialize_image_media_refs(
+ json.loads(conversation.history), self.media_store, strict=True
+ )
export_record = {
"cid": cid,
"user_id": user_id,
@@ -271,6 +288,8 @@ async def export_conversations(self, data: object) -> ConversationExport:
}
jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
exported_count += 1
+ except MemoryError:
+ raise
except Exception as exc:
failed_items.append(f"user_id:{user_id}, cid:{cid} - {exc!s}")
logger.error(
@@ -290,6 +309,70 @@ async def export_conversations(self, data: object) -> ConversationExport:
filename=f"astrbot_conversations_export_{timestamp}.jsonl",
)
+ async def get_conversation_media(
+ self, user_id: str, cid: str, media_id: str
+ ) -> ConversationMedia:
+ """Return one image referenced by an authorized conversation.
+
+ Args:
+ user_id: Conversation owner and unified message origin.
+ cid: Conversation identifier.
+ media_id: Content hash from the persisted media reference.
+
+ Returns:
+ Image bytes and their persisted MIME type.
+
+ Raises:
+ ConversationServiceError: If ownership, reference, or media access fails.
+ """
+ conversation = await self.db_helper.get_conversation_by_id(cid)
+ if not conversation:
+ raise ConversationServiceError("对话不存在")
+ if conversation.user_id != user_id:
+ raise ConversationServiceError("对话不存在")
+ try:
+ history = json.loads(conversation.history)
+ except (TypeError, json.JSONDecodeError) as exc:
+ raise ConversationServiceError("对话历史无效") from exc
+ refs = self._media_refs(history)
+ ref = refs.get(media_id)
+ if ref is None:
+ raise ConversationServiceError("媒体不存在")
+ try:
+ return ConversationMedia(
+ await asyncio.to_thread(self.media_store.read, ref, set(refs)),
+ ref.mime_type,
+ )
+ except (OSError, PermissionError, FileNotFoundError) as exc:
+ raise ConversationServiceError("媒体暂时不可用,请从原始来源恢复") from exc
+
+ @staticmethod
+ def _media_refs(value) -> dict[str, ImageMediaRef]:
+ found = {}
+ if isinstance(value, list):
+ for item in value:
+ found.update(ConversationService._media_refs(item))
+ elif isinstance(value, dict):
+ if value.get("type") == "image_media_ref":
+ try:
+ ref = ImageMediaRef(
+ value["media_id"],
+ value["mime_type"],
+ value.get("width"),
+ value.get("height"),
+ value["byte_size"],
+ value.get("detail"),
+ value.get("version", 1),
+ value.get("image_id"),
+ )
+ found[ref.media_id] = ref
+ except (KeyError, TypeError, ValueError):
+ pass
+ else:
+ for item in value.values():
+ found.update(ConversationService._media_refs(item))
+ return found
+
async def _delete_conversations(self, conversations: object) -> dict:
if not isinstance(conversations, list) or not conversations:
raise ConversationServiceError("批量删除时conversations参数不能为空")
diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts
index 973b1971bb..7f9b52726f 100644
--- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts
+++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios';
-import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen';
+import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, PreviewConversationMediaData, PreviewConversationMediaError, PreviewConversationMediaResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen';
export const client = createClient(createConfig());
@@ -2616,6 +2616,16 @@ export const replaceConversationMessages = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/conversations/{conversation_id}/media/{media_id}'
+ });
+};
+
/**
* Export conversations
*/
diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts
index 37cad88028..8ee3f69094 100644
--- a/dashboard/src/api/generated/openapi-v1/types.gen.ts
+++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts
@@ -3297,6 +3297,20 @@ export type ReplaceConversationMessagesResponse = (SuccessEnvelope);
export type ReplaceConversationMessagesError = unknown;
+export type PreviewConversationMediaData = {
+ path: {
+ conversation_id: string;
+ media_id: string;
+ };
+ query: {
+ user_id: string;
+ };
+};
+
+export type PreviewConversationMediaResponse = ((Blob | File));
+
+export type PreviewConversationMediaError = (unknown);
+
export type ExportConversationsData = {
body: ConversationExportRequest;
};
diff --git a/dashboard/src/components/chat/AuthenticatedMediaImage.vue b/dashboard/src/components/chat/AuthenticatedMediaImage.vue
new file mode 100644
index 0000000000..4b7e770aed
--- /dev/null
+++ b/dashboard/src/components/chat/AuthenticatedMediaImage.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+ {{ unavailableText }}
+
diff --git a/dashboard/src/components/conversation/ConversationHistoryPreview.vue b/dashboard/src/components/conversation/ConversationHistoryPreview.vue
index 11f660291d..86c8340ee2 100644
--- a/dashboard/src/components/conversation/ConversationHistoryPreview.vue
+++ b/dashboard/src/components/conversation/ConversationHistoryPreview.vue
@@ -4,8 +4,13 @@ import { ChevronRight, Minus, Plus } from "@lucide/vue";
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
import { useModuleI18n } from "@/i18n/composables";
+import AuthenticatedMediaImage from "@/components/chat/AuthenticatedMediaImage.vue";
-const props = defineProps<{ messages: unknown[] }>();
+const props = defineProps<{
+ messages: unknown[];
+ conversationId?: string;
+ userId?: string;
+}>();
const { tm } = useModuleI18n("features/conversation");
const markdownEnabled = ref(true);
const fontSize = ref(13);
@@ -54,8 +59,18 @@ type PreviewPart = {
text: string;
html?: string;
label?: string;
+ mediaId?: string;
};
+function mediaUrl(mediaId: string) {
+ if (!props.conversationId || !props.userId) return "";
+ return `/api/v1/conversations/${encodeURIComponent(
+ props.conversationId,
+ )}/media/${encodeURIComponent(mediaId)}?user_id=${encodeURIComponent(
+ props.userId,
+ )}`;
+}
+
const records = computed(() =>
props.messages
.filter(
@@ -103,6 +118,16 @@ const records = computed(() =>
)
) {
parts.push({ kind: "image", text: item.image_url.url });
+ } else if (
+ item?.type === "image_media_ref" &&
+ typeof item.media_id === "string" &&
+ /^[0-9a-f]{64}$/i.test(item.media_id)
+ ) {
+ parts.push({
+ kind: "image",
+ text: "",
+ mediaId: item.media_id,
+ });
} else {
parts.push({ kind: "data", text: JSON.stringify(item, null, 2) });
}
@@ -265,8 +290,18 @@ const records = computed(() =>
{{
part.text
}}
+
diff --git a/docs/en/dev/openapi-scopes.md b/docs/en/dev/openapi-scopes.md
index 4bf87c6c4b..7b44c6e9b7 100644
--- a/docs/en/dev/openapi-scopes.md
+++ b/docs/en/dev/openapi-scopes.md
@@ -184,6 +184,7 @@ Manage conversations and platform-session data.
| `GET` | `/api/v1/conversations/{conversation_id}` | — |
| `PATCH` | `/api/v1/conversations/{conversation_id}` | — |
| `DELETE` | `/api/v1/conversations/{conversation_id}` | — |
+| `GET` | `/api/v1/conversations/{conversation_id}/media/{media_id}` | — |
| `PUT` | `/api/v1/conversations/{conversation_id}/messages` | — |
| `GET` | `/api/v1/session-groups` | — |
| `POST` | `/api/v1/session-groups` | — |
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index b666e92afa..512eca8393 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -6418,6 +6418,55 @@
"description": "**Required scope:** `data`"
}
},
+ "/api/v1/conversations/{conversation_id}/media/{media_id}": {
+ "get": {
+ "tags": [
+ "Conversations"
+ ],
+ "summary": "Preview an image referenced by a conversation",
+ "operationId": "previewConversationMedia",
+ "x-astrbot-scope": "data",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ConversationId"
+ },
+ {
+ "name": "media_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ }
+ },
+ {
+ "name": "user_id",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Image bytes",
+ "content": {
+ "image/*": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation or media is unavailable"
+ }
+ },
+ "description": "**Required scope:** `data`"
+ }
+ },
"/api/v1/conversations/{conversation_id}/messages": {
"put": {
"tags": [
diff --git a/docs/zh/dev/openapi-scopes.md b/docs/zh/dev/openapi-scopes.md
index 3152454eb3..36146c7ef8 100644
--- a/docs/zh/dev/openapi-scopes.md
+++ b/docs/zh/dev/openapi-scopes.md
@@ -184,6 +184,7 @@ outline: deep
| `GET` | `/api/v1/conversations/{conversation_id}` | — |
| `PATCH` | `/api/v1/conversations/{conversation_id}` | — |
| `DELETE` | `/api/v1/conversations/{conversation_id}` | — |
+| `GET` | `/api/v1/conversations/{conversation_id}/media/{media_id}` | — |
| `PUT` | `/api/v1/conversations/{conversation_id}/messages` | — |
| `GET` | `/api/v1/session-groups` | — |
| `POST` | `/api/v1/session-groups` | — |
diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml
index ab63053bf0..fb9f3bba6f 100644
--- a/openspec/openapi-v1.yaml
+++ b/openspec/openapi-v1.yaml
@@ -4351,6 +4351,36 @@ paths:
"200":
$ref: "#/components/responses/Ok"
+ /api/v1/conversations/{conversation_id}/media/{media_id}:
+ get:
+ tags: [Conversations]
+ summary: Preview an image referenced by a conversation
+ operationId: previewConversationMedia
+ x-astrbot-scope: data
+ parameters:
+ - $ref: "#/components/parameters/ConversationId"
+ - name: media_id
+ in: path
+ required: true
+ schema:
+ type: string
+ pattern: "^[0-9a-f]{64}$"
+ - name: user_id
+ in: query
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Image bytes
+ content:
+ image/*:
+ schema:
+ type: string
+ format: binary
+ "404":
+ description: Conversation or media is unavailable
+
/api/v1/conversations/export:
post:
tags: [Conversations]
diff --git a/tests/test_agent_runner_media_resolver.py b/tests/test_agent_runner_media_resolver.py
index cd69f3aee1..4b5bbf289a 100644
--- a/tests/test_agent_runner_media_resolver.py
+++ b/tests/test_agent_runner_media_resolver.py
@@ -1,11 +1,20 @@
import base64
from io import BytesIO
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
import pytest
from PIL import Image as PILImage
+from astrbot.core.agent.runners.base import AgentState
from astrbot.core.agent.runners.coze.coze_agent_runner import CozeAgentRunner
+from astrbot.core.agent.runners.deerflow.deerflow_agent_runner import (
+ DeerFlowAgentRunner,
+)
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
+from astrbot.core.provider.entities import ProviderRequest
+from astrbot.core.utils.image_media_store import ImageMediaStore
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
def _png_data_url() -> tuple[str, bytes]:
@@ -62,3 +71,108 @@ async def upload_file(self, file_data: bytes) -> str:
assert file_id == "file-1"
assert captured["file_data"] == image_bytes
assert list(runner.file_id_cache["session-1"].values()) == ["file-1"]
+
+
+@pytest.mark.asyncio
+async def test_coze_history_reference_is_materialized_before_upload(
+ tmp_path, monkeypatch
+):
+ import astrbot.core.agent.runners.coze.coze_agent_runner as coze_module
+
+ image_ref, image_bytes = _png_data_url()
+ stored = ImageMediaStore(tmp_path / "media").put(
+ base64.b64decode(image_ref.split(",", 1)[1])
+ )
+ captured: dict[str, object] = {}
+ uploaded: list[str] = []
+ upload_options = []
+
+ async def get_async(*_args, **_kwargs):
+ return ""
+
+ monkeypatch.setattr(coze_module.sp, "get_async", get_async)
+ monkeypatch.setattr(
+ coze_module,
+ "get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+
+ class _FakeCozeClient:
+ async def chat_messages(self, **kwargs):
+ captured.update(kwargs)
+ yield {"event": "conversation.message.completed", "data": {}}
+ yield {"event": "conversation.chat.completed", "data": {}}
+
+ runner = CozeAgentRunner.__new__(CozeAgentRunner)
+ runner.req = ProviderRequest(
+ session_id="session-1",
+ contexts=[{"role": "user", "content": [stored.model_dump()]}],
+ )
+ runner.auto_save_history = False
+ runner.api_client = _FakeCozeClient()
+ runner.bot_id = "bot-1"
+ runner.timeout = 10
+ runner.streaming = False
+ runner._state = AgentState.RUNNING
+
+ async def on_agent_done(*_args, **_kwargs):
+ return None
+
+ runner.agent_hooks = SimpleNamespace(
+ on_agent_done=on_agent_done,
+ )
+ runner.run_context = SimpleNamespace()
+
+ async def capture_upload(image_url, _session_id, *, image_options=None):
+ uploaded.append(image_url)
+ upload_options.append(image_options)
+ return "file-1"
+
+ runner._download_and_upload_image = capture_upload
+
+ responses = [response async for response in runner._execute_coze_request()]
+
+ assert len(responses) == 1
+ assert len(uploaded) == 1
+ assert upload_options[0].enabled is False
+ assert base64.b64decode(uploaded[0].split(",", 1)[1]) == image_bytes
+ assert captured["additional_messages"][0]["content"][0] == {
+ "type": "file",
+ "file_id": "file-1",
+ "file_url": uploaded[0],
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("runner_cls", "execute_method"),
+ [
+ (DifyAgentRunner, "_execute_dify_request"),
+ (CozeAgentRunner, "_execute_coze_request"),
+ (DeerFlowAgentRunner, "_execute_deerflow_request"),
+ ],
+)
+async def test_resource_failure_is_not_converted_to_agent_response(
+ runner_cls, execute_method
+):
+ failure = ImagePayloadTooLargeError("image exceeds the request budget")
+
+ async def failed_request():
+ raise failure
+ yield # pragma: no cover
+
+ runner = runner_cls.__new__(runner_cls)
+ runner.req = ProviderRequest(prompt="hello")
+ runner._state = AgentState.IDLE
+ runner.agent_hooks = SimpleNamespace(on_agent_begin=AsyncMock())
+ runner.run_context = SimpleNamespace()
+ setattr(runner, execute_method, failed_request)
+ if runner_cls in {DifyAgentRunner, CozeAgentRunner}:
+ runner.api_client = SimpleNamespace(close=AsyncMock())
+
+ with pytest.raises(ImagePayloadTooLargeError) as caught:
+ async for _ in runner.step():
+ pass
+
+ assert caught.value is failure
+ assert runner._state == AgentState.ERROR
diff --git a/tests/test_chat_route.py b/tests/test_chat_route.py
index 37881182db..02f749563b 100644
--- a/tests/test_chat_route.py
+++ b/tests/test_chat_route.py
@@ -508,3 +508,39 @@ async def test_chat_stream_forwards_follow_up_status_by_default(
run.task.cancel()
await asyncio.gather(run.task, return_exceptions=True)
chat_service.webchat_queue_mgr.remove_queues(session_id)
+
+
+@pytest.mark.asyncio
+async def test_save_uploaded_file_rejects_oversized_content_length(
+ chat_service_instance,
+):
+ class FakeUpload:
+ filename = "big.bin"
+ content_type = "application/octet-stream"
+ content_length = chat_service.MAX_UPLOAD_FILE_SIZE_BYTES + 1
+
+ with pytest.raises(ChatServiceError, match="File too large"):
+ await chat_service_instance.save_uploaded_file(FakeUpload())
+
+
+@pytest.mark.asyncio
+async def test_save_uploaded_file_rejects_oversized_saved_file(
+ chat_service_instance,
+):
+ from pathlib import Path
+
+ class FakeUpload:
+ filename = "big.bin"
+ content_type = "application/octet-stream"
+ content_length = None
+
+ async def save(self, path):
+ saved = Path(path)
+ saved.write_bytes(b"x")
+ with saved.open("rb+") as f:
+ f.truncate(chat_service.MAX_UPLOAD_FILE_SIZE_BYTES + 1)
+
+ with pytest.raises(ChatServiceError, match="File too large"):
+ await chat_service_instance.save_uploaded_file(FakeUpload())
+
+ assert not list(Path(chat_service_instance.attachments_dir).iterdir())
diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py
index a2f26670d8..d5b91d892f 100644
--- a/tests/test_computer_fs_tools.py
+++ b/tests/test_computer_fs_tools.py
@@ -19,6 +19,7 @@
from astrbot.core.computer.booters.local import LocalBooter
from astrbot.core.tools.computer_tools import fs as fs_tools
from astrbot.core.tools.computer_tools import util as computer_util
+from astrbot.core.utils import media_utils
def _make_context(
@@ -184,12 +185,6 @@ def _setup_local_fs_tools(
"get_astrbot_temp_path",
lambda: str(temp_root),
)
- monkeypatch.setattr(
- file_read_utils,
- "get_astrbot_temp_path",
- lambda: str(temp_root),
- )
-
booter = LocalBooter()
async def _fake_get_booter(_ctx, _umo):
@@ -861,8 +856,72 @@ async def test_file_read_tool_returns_image_call_tool_result_for_images(
assert isinstance(result, CallToolResult)
assert len(result.content) == 1
assert isinstance(result.content[0], ImageContent)
- assert result.content[0].mimeType == "image/jpeg"
- assert base64.b64decode(result.content[0].data).startswith(b"\xff\xd8\xff")
+ assert result.content[0].mimeType == "image/png"
+ assert base64.b64decode(result.content[0].data) == image_path.read_bytes()
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(os.name == "nt", reason="Restricted file access needs POSIX.")
+async def test_file_read_tool_rejects_oversized_image_before_reading(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ image_path = workspace / "oversized.png"
+ image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
+ with image_path.open("ab") as image_file:
+ image_file.truncate(media_utils.MODEL_IMAGE_MAX_INPUT_BYTES + 1)
+
+ async def fail_read(*_args, **_kwargs):
+ raise AssertionError("oversized image must be rejected before reading")
+
+ monkeypatch.setattr(file_read_utils, "_read_local_file_bytes", fail_read)
+ result = await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="oversized.png",
+ )
+
+ assert isinstance(result, str)
+ assert "Image input exceeds" in result
+
+
+@pytest.mark.asyncio
+async def test_local_file_read_defers_image_preparation_to_tool_loop(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ image_path = workspace / "sample.png"
+ Image.new("RGB", (32, 16), color=(255, 0, 0)).save(image_path, format="PNG")
+ result = await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="sample.png",
+ )
+
+ assert isinstance(result, CallToolResult)
+ assert result.content[0].data
+ assert base64.b64decode(result.content[0].data) == image_path.read_bytes()
+
+
+@pytest.mark.asyncio
+async def test_file_read_propagates_image_memory_error(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ image_path = workspace / "sample.png"
+ Image.new("RGB", (32, 16), color=(255, 0, 0)).save(image_path, format="PNG")
+
+ async def fail_read(*_args, **_kwargs):
+ raise MemoryError("image preparation exhausted memory")
+
+ monkeypatch.setattr(file_read_utils, "_read_local_file_bytes", fail_read)
+
+ with pytest.raises(MemoryError, match="exhausted memory"):
+ await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="sample.png",
+ )
@pytest.mark.asyncio
diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py
index f9262204ff..f8d4bad1e3 100644
--- a/tests/test_fastapi_v1_dashboard.py
+++ b/tests/test_fastapi_v1_dashboard.py
@@ -1671,6 +1671,46 @@ async def test_v1_conversation_detail_requires_user_id(
assert response.status_code == 422
+@pytest.mark.asyncio
+async def test_v1_conversation_media_route_requires_auth_and_owner(
+ asgi_client: httpx.AsyncClient,
+ monkeypatch,
+):
+ from astrbot.dashboard.services.conversation_service import (
+ ConversationMedia,
+ ConversationService,
+ ConversationServiceError,
+ )
+
+ async def preview(self, user_id: str, cid: str, media_id: str):
+ if user_id != "owner" or cid != "cid" or media_id != "a" * 64:
+ raise ConversationServiceError("对话不存在")
+ return ConversationMedia(b"valid-image", "image/png")
+
+ monkeypatch.setattr(ConversationService, "get_conversation_media", preview)
+ path = "/api/v1/conversations/cid/media/" + "a" * 64
+ assert (await asgi_client.get(path, params={"user_id": "owner"})).status_code == 401
+
+ response = await asgi_client.get(
+ path, params={"user_id": "owner"}, headers=_jwt_headers()
+ )
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "image/png"
+ assert response.content == b"valid-image"
+
+ other_owner = await asgi_client.get(
+ path, params={"user_id": "other"}, headers=_jwt_headers()
+ )
+ assert other_owner.status_code == 400
+
+ unreferenced = await asgi_client.get(
+ "/api/v1/conversations/cid/media/" + "b" * 64,
+ params={"user_id": "owner"},
+ headers=_jwt_headers(),
+ )
+ assert unreferenced.status_code == 400
+
+
@pytest.mark.asyncio
async def test_dashboard_alias_conversation_detail_uses_fastapi_service(
asgi_client: httpx.AsyncClient,
diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py
index 976efc6db3..05f8e3aa78 100644
--- a/tests/test_media_utils.py
+++ b/tests/test_media_utils.py
@@ -324,6 +324,58 @@ async def test_compress_image_preserves_alpha_png(tmp_path, monkeypatch):
compressed_path.unlink(missing_ok=True)
+@pytest.mark.asyncio
+async def test_compress_image_reencodes_sources_over_one_megabyte(
+ tmp_path, monkeypatch
+):
+ """Sources above the legacy threshold still enter the compression path."""
+ from PIL import Image as PILImage
+
+ temp_dir = tmp_path / "temp"
+ monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
+ image_path = tmp_path / "large.png"
+ with PILImage.frombytes("RGB", (768, 768), os.urandom(768 * 768 * 3)) as image:
+ image.save(image_path, format="PNG")
+
+ assert (
+ image_path.stat().st_size
+ > media_utils.IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES
+ )
+ compressed_path = Path(await media_utils.compress_image(str(image_path)))
+
+ try:
+ assert compressed_path != image_path
+ assert compressed_path.suffix == ".jpg"
+ assert compressed_path.read_bytes().startswith(b"\xff\xd8")
+ assert image_path.read_bytes().startswith(b"\x89PNG")
+ finally:
+ compressed_path.unlink(missing_ok=True)
+
+
+@pytest.mark.asyncio
+async def test_compress_image_rejects_oversized_encoded_payload(tmp_path, monkeypatch):
+ from PIL import Image as PILImage
+
+ temp_dir = tmp_path / "temp"
+ monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
+ image_path = tmp_path / "high_entropy.png"
+ image = PILImage.new("RGBA", (32, 32))
+ image.putdata(
+ [
+ (index % 256, (index * 7) % 256, (index * 13) % 256, 255)
+ for index in range(1024)
+ ]
+ )
+ image.save(image_path, format="PNG")
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError, match="encoding limit"):
+ await media_utils.compress_image(
+ str(image_path), max_size=128, max_encoded_bytes=1
+ )
+
+ assert not temp_dir.exists() or not list(temp_dir.iterdir())
+
+
@pytest.mark.asyncio
async def test_compress_image_keeps_animated_gif(tmp_path, monkeypatch):
from PIL import Image as PILImage
@@ -343,7 +395,7 @@ async def test_compress_image_keeps_animated_gif(tmp_path, monkeypatch):
compressed_path = await media_utils.compress_image(str(image_path), max_size=2)
assert compressed_path == str(image_path)
- assert not list(temp_dir.iterdir())
+ assert not temp_dir.exists() or not list(temp_dir.iterdir())
@pytest.mark.asyncio
@@ -873,3 +925,75 @@ async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(
assert len(fake.calls) == 1
assert fake.calls[0]["sample_rate"] == 24000
+
+
+@pytest.mark.asyncio
+async def test_prepare_model_image_skips_oversized_input(tmp_path, monkeypatch):
+ """Inputs above the model-image byte cap must be skipped before decoding."""
+ from PIL import Image as PILImage
+
+ monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
+ image_path = tmp_path / "oversized.png"
+ PILImage.new("RGB", (4, 4)).save(image_path, format="PNG")
+ with image_path.open("ab") as f:
+ f.truncate(media_utils.MODEL_IMAGE_MAX_INPUT_BYTES + 1)
+
+ def fail_read(*_args, **_kwargs):
+ raise AssertionError("oversized image must be rejected before reading")
+
+ monkeypatch.setattr(Path, "read_bytes", fail_read)
+
+ result = await media_utils.prepare_model_image(
+ str(image_path), max_size=1280, output_dir=tmp_path
+ )
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_prepare_image_source_rejects_oversized_data_uri_before_decode(
+ monkeypatch,
+):
+ """Inline payloads must be bounded before base64 decoding allocates bytes."""
+ monkeypatch.setattr(media_utils, "MODEL_IMAGE_MAX_INPUT_BYTES", 4)
+
+ def fail_decode(*_args, **_kwargs):
+ raise AssertionError("oversized data URI must not be decoded")
+
+ monkeypatch.setattr(media_utils, "_decode_base64_payload", fail_decode)
+ image_ref = "data:image/png;base64," + base64.b64encode(b"12345").decode()
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError, match="input exceeds"):
+ await media_utils.prepare_image_source(image_ref)
+
+
+def test_convert_image_bytes_reuses_small_in_range_input():
+ """A small oriented in-range PNG keeps its original bytes."""
+ from PIL import Image as PILImage
+
+ buffer = BytesIO()
+ PILImage.new("RGB", (10, 10), (255, 0, 0)).save(buffer, format="PNG")
+ source = buffer.getvalue()
+
+ result = media_utils._convert_image_bytes_sync(source, 1280, 95)
+
+ assert result is source
+
+
+def test_convert_image_bytes_reencodes_large_in_range_input(tmp_path, monkeypatch):
+ """An in-range but byte-heavy PNG is re-encoded instead of reused."""
+ from PIL import Image as PILImage
+
+ monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
+ img = PILImage.new("RGB", (1024, 1024))
+ img.frombytes(os.urandom(1024 * 1024 * 3))
+ buffer = BytesIO()
+ img.save(buffer, format="PNG")
+ source = buffer.getvalue()
+ assert len(source) > media_utils.IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES
+
+ result = media_utils._convert_image_bytes_sync(source, 1280, 95)
+
+ assert result is not source
+ assert result[:2] == b"\xff\xd8"
+ assert len(result) < len(source)
diff --git a/tests/test_openai_responses_source.py b/tests/test_openai_responses_source.py
index 6b2d5e6718..04192c4041 100644
--- a/tests/test_openai_responses_source.py
+++ b/tests/test_openai_responses_source.py
@@ -1,13 +1,16 @@
+import io
import json
from types import SimpleNamespace
import pytest
from openai.types.responses import Response
+from PIL import Image
from astrbot.core.config.default import CONFIG_METADATA_2
from astrbot.core.provider.sources.openai_responses_source import (
ProviderOpenAIResponses,
)
+from astrbot.core.utils.image_media_store import ImageMediaStore
def _make_provider(overrides: dict | None = None) -> ProviderOpenAIResponses:
@@ -208,6 +211,32 @@ async def test_prepare_payload_replays_full_history_without_server_state():
assert "conversation" not in payloads
+@pytest.mark.asyncio
+async def test_prepare_payload_materializes_durable_image_reference(
+ tmp_path, monkeypatch
+):
+ output = io.BytesIO()
+ Image.new("RGB", (3, 2), "red").save(output, format="PNG")
+ data = output.getvalue()
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(data, "image/png", detail="high")
+ monkeypatch.setattr(
+ "astrbot.core.provider.sources.openai_responses_source.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ provider = _make_provider()
+
+ payloads, context = await provider._prepare_chat_payload(
+ prompt=None,
+ contexts=[{"role": "user", "content": [ref.model_dump()]}],
+ )
+
+ image = context[0]["content"][0]["image_url"]
+ assert image["url"].startswith("data:image/png;base64,")
+ assert image["detail"] == "high"
+ assert payloads["input"][0]["content"][0]["type"] == "input_image"
+
+
@pytest.mark.asyncio
async def test_query_flattens_tools_and_enforces_stateless_body(monkeypatch):
provider = _make_provider(
diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py
index e92d90f8d8..1fe68e317c 100644
--- a/tests/test_openai_source.py
+++ b/tests/test_openai_source.py
@@ -1,10 +1,12 @@
import base64
import builtins
from io import BytesIO
+from pathlib import Path
from types import SimpleNamespace
import httpx
import pytest
+from openai import AsyncAzureOpenAI, AsyncOpenAI
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from PIL import Image as PILImage
@@ -15,8 +17,6 @@
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
-from pathlib import Path
-
from astrbot.core.utils.media_utils import ResolvedMediaData, file_uri_to_path
@@ -62,6 +62,54 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
)
+@pytest.mark.parametrize(
+ ("overrides", "expected_client"),
+ [
+ ({}, AsyncOpenAI),
+ ({"api_version": "2024-02-01"}, AsyncAzureOpenAI),
+ ],
+)
+@pytest.mark.asyncio
+async def test_provider_client_disables_sdk_builtin_retries(overrides, expected_client):
+ provider = _make_provider(overrides)
+ try:
+ assert isinstance(provider.client, expected_client)
+ assert provider.client.max_retries == 0
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_query_attempts_exactly_request_max_retries_times(monkeypatch):
+ monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
+ monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
+
+ provider = _make_provider()
+ try:
+ calls = 0
+
+ async def failing_create(**kwargs):
+ nonlocal calls
+ calls += 1
+ raise httpx.ConnectError("temporary connection failure")
+
+ monkeypatch.setattr(provider.client.chat.completions, "create", failing_create)
+
+ with pytest.raises(httpx.ConnectError):
+ await provider._query(
+ payloads={
+ "model": "gpt-4o-mini",
+ "messages": [{"role": "user", "content": "hi"}],
+ },
+ tools=None,
+ request_max_retries=2,
+ )
+
+ assert calls == 2
+ finally:
+ await provider.terminate()
+
+
def test_create_http_client_uses_openai_httpx_module(monkeypatch):
captured: dict[str, object] = {}
fake_httpx_module = object()
@@ -725,10 +773,12 @@ async def fake_resolve_media_ref_to_base64_data(
*,
media_type: str,
strict: bool = False,
+ image_options=None,
) -> ResolvedMediaData:
assert media_ref == "https://example.com/quoted.png"
assert media_type == "image"
assert strict is False
+ assert image_options is not None
return ResolvedMediaData(base64_data="abcd", mime_type="image/png")
monkeypatch.setattr(
@@ -995,9 +1045,15 @@ async def test_materialize_context_image_parts_returns_new_messages(monkeypatch)
{"role": "assistant", "content": "plain text"},
]
- async def fake_resolve(image_url: str, *, image_detail: str | None = None):
+ async def fake_resolve(
+ image_url: str,
+ *,
+ image_detail: str | None = None,
+ options=None,
+ ):
assert image_url == "https://example.com/quoted.png"
assert image_detail == "high"
+ assert options is not None
return {
"type": "image_url",
"image_url": {
@@ -1028,6 +1084,35 @@ async def fake_resolve(image_url: str, *, image_detail: str | None = None):
await provider.terminate()
+@pytest.mark.asyncio
+async def test_materialize_context_keeps_existing_data_image_without_reencoding(
+ monkeypatch,
+):
+ provider = _make_provider()
+ try:
+ image_part = {
+ "type": "image_url",
+ "image_url": {
+ "url": "data:image/png;base64,already-prepared",
+ "detail": "high",
+ "id": "request-image",
+ },
+ }
+
+ async def fail_if_resolved(*_args, **_kwargs):
+ raise AssertionError("an existing data URL must not be re-encoded")
+
+ monkeypatch.setattr(provider, "_resolve_image_part", fail_if_resolved)
+
+ materialized = await provider._materialize_context_image_parts(
+ [{"role": "user", "content": [image_part]}]
+ )
+
+ assert materialized[0]["content"][0] == image_part
+ finally:
+ await provider.terminate()
+
+
@pytest.mark.asyncio
async def test_encode_image_bs64_missing_file_raises(tmp_path):
provider = _make_provider()
diff --git a/tests/test_process_stage_images.py b/tests/test_process_stage_images.py
index 44c2aba77b..7c9f60aef6 100644
--- a/tests/test_process_stage_images.py
+++ b/tests/test_process_stage_images.py
@@ -19,6 +19,7 @@
TextPart,
dump_messages_with_checkpoints,
)
+from astrbot.core.agent.runners import tool_loop_agent_runner
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.message.components import Image, Plain, Reply
from astrbot.core.pipeline.preprocess_stage import stage as preprocess
@@ -35,6 +36,10 @@
from astrbot.core.provider.provider import Provider
from astrbot.core.star.star_handler import EventType
from astrbot.core.utils import media_utils as media
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ materialize_image_media_refs,
+)
def make_event(parts=None, text="hello", session="images"):
@@ -366,6 +371,11 @@ async def test_profile_reload_and_concurrent_requests(harness, tmp_path):
async def test_plugin_request_extra_metadata_hook_and_history(
harness, tmp_path, monkeypatch
):
+ monkeypatch.setattr(
+ tool_loop_agent_runner,
+ "get_astrbot_data_path",
+ lambda: str(tmp_path / "data"),
+ )
source = source_image(tmp_path)
replacement = source_image(tmp_path, "BMP")
extra = ImageURLPart(
@@ -438,7 +448,14 @@ async def hook(event, kind, *args):
== historical[0]["content"][0]["image_url"]["url"]
)
assert req.contexts == historical
- images = [p for p in saved[-1]["content"] if p["type"] == "image_url"]
+ refs = [p for p in saved[-1]["content"] if p["type"] == "image_media_ref"]
+ assert refs and all(len(p["media_id"]) == 64 for p in refs)
+ materialized = await materialize_image_media_refs(
+ [saved[-1]], ImageMediaStore(tmp_path / "data" / "media")
+ )
+ images = [
+ p for p in materialized[0]["content"] if p["type"] == "image_url"
+ ]
assert images and all(
p["image_url"]["url"].startswith("data:image/jpeg;base64,") for p in images
)
@@ -455,7 +472,7 @@ async def hook(event, kind, *args):
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
anthropic = object.__new__(ProviderAnthropic)
- _, payload = anthropic._prepare_payload([saved[-1]])
+ _, payload = anthropic._prepare_payload(materialized)
visual = [part for part in payload[0]["content"] if part["type"] == "image"]
assert len(visual) == len(images)
assert all(part["source"]["media_type"] == "image/jpeg" for part in visual)
diff --git a/tests/test_storage_cleaner.py b/tests/test_storage_cleaner.py
index 5d6f82c515..719d625636 100644
--- a/tests/test_storage_cleaner.py
+++ b/tests/test_storage_cleaner.py
@@ -1,7 +1,10 @@
import base64
from pathlib import Path
+import pytest
+
from astrbot.core.agent.tool_image_cache import tool_image_cache
+from astrbot.core.utils import media_utils
from astrbot.core.utils.storage_cleaner import StorageCleaner
@@ -111,3 +114,14 @@ def test_tool_image_cache_recovers_after_storage_cleanup(tmp_path, monkeypatch):
assert cached_path == cache_dir / "call-test_0.jpg"
assert cached_path.read_bytes() == image_bytes
+
+
+def test_tool_image_cache_rejects_oversized_image_before_decode(monkeypatch):
+ monkeypatch.setattr(media_utils, "MODEL_IMAGE_MAX_INPUT_BYTES", 4)
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError, match="input exceeds"):
+ tool_image_cache.save_image(
+ base64_data=base64.b64encode(b"12345").decode("ascii"),
+ tool_call_id="call-too-large",
+ tool_name="test-tool",
+ )
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index 180e0edf2d..bd68e7ca22 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -133,7 +133,10 @@ async def generator():
content=[
ImageContent(
type="image",
- data="dGVzdA==",
+ data=(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ ),
mimeType="image/png",
),
TextContent(type="text", text="直播间标题:新游首发:零~红蝶~"),
@@ -938,10 +941,26 @@ def fake_save_image(
}
)
return SimpleNamespace(
- file_path=f"/tmp/{tool_call_id}_{index}.png", mime_type=mime_type
+ file_path=f"/tmp/{tool_call_id}_{index}.png",
+ mime_type=mime_type,
+ tool_name=tool_name,
)
monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)
+ from astrbot.core.utils.media_utils import ResolvedMediaData
+
+ monkeypatch.setattr(
+ "astrbot.core.agent.runners.tool_loop_agent_runner.prepare_image_source",
+ AsyncMock(
+ return_value=ResolvedMediaData(
+ base64_data=(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ ),
+ mime_type="image/png",
+ )
+ ),
+ )
await runner.reset(
provider=mock_provider,
@@ -965,7 +984,10 @@ def fake_save_image(
assert "直播间标题:新游首发:零~红蝶~" in content
assert saved_images == [
{
- "base64_data": "dGVzdA==",
+ "base64_data": (
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ ),
"tool_call_id": "call_123",
"tool_name": "test_tool",
"index": 0,
@@ -1252,6 +1274,297 @@ async def test_same_tool_streak_resets_after_switching_tools(
assert level_2_notice in content
+@pytest.mark.asyncio
+@pytest.mark.parametrize("error_type", [MemoryError])
+async def test_resource_exhaustion_does_not_try_fallback(
+ runner, provider_request, mock_tool_executor, mock_hooks, error_type
+):
+ primary = MockProvider()
+ fallback = MockProvider()
+ primary.text_chat = AsyncMock(side_effect=error_type("resource exhausted"))
+ await runner.reset(
+ provider=primary,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ fallback_providers=[fallback],
+ )
+ with pytest.raises(error_type):
+ async for _ in runner._iter_llm_responses_with_fallback():
+ pass
+ assert fallback.call_count == 0
+
+
+@pytest.mark.asyncio
+async def test_http_413_is_readable_and_not_retried(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ import httpx
+
+ from astrbot.core.exceptions import ProviderRequestTooLargeError
+
+ primary = MockProvider()
+ fallback = MockProvider()
+ request = httpx.Request("POST", "https://invalid.test/chat")
+ response = httpx.Response(413, request=request)
+ error = httpx.HTTPStatusError("too large", request=request, response=response)
+ primary.text_chat = AsyncMock(side_effect=error)
+ await runner.reset(
+ provider=primary,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ fallback_providers=[fallback],
+ )
+ with pytest.raises(ProviderRequestTooLargeError, match="HTTP 413"):
+ async for _ in runner._iter_llm_responses_with_fallback():
+ pass
+ assert primary.text_chat.await_count == 1
+ assert fallback.call_count == 0
+
+
+@pytest.mark.asyncio
+async def test_tool_memory_failure_is_not_returned_as_empty_tool_error(
+ runner, provider_request, mock_hooks
+):
+ failure = MemoryError()
+
+ class FailedExecutor:
+ @classmethod
+ def execute(cls, *args, **kwargs):
+ async def results():
+ raise failure
+ yield # pragma: no cover
+
+ return results()
+
+ provider = MockProvider()
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=FailedExecutor,
+ agent_hooks=mock_hooks,
+ )
+ with pytest.raises(MemoryError) as caught:
+ async for _ in runner.step_until_done(3):
+ pass
+ assert caught.value is failure
+ assert provider.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_real_tool_image_uses_preparation_and_reference_storage(
+ runner, provider_request, mock_hooks, tmp_path, monkeypatch
+):
+ import base64
+ import io
+
+ from mcp.types import CallToolResult, ImageContent
+ from PIL import Image
+
+ import astrbot.core.agent.runners.tool_loop_agent_runner as runner_module
+ from astrbot.core.agent.message import ImageMediaRefPart
+ from astrbot.core.agent.tool_image_cache import tool_image_cache
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ output = io.BytesIO()
+ with Image.new("RGBA", (24, 16), (60, 20, 30, 128)) as image:
+ image.save(output, "WEBP", lossless=True)
+ encoded = base64.b64encode(output.getvalue()).decode()
+
+ class ImageExecutor:
+ @classmethod
+ def execute(cls, *args, **kwargs):
+ async def results():
+ yield CallToolResult(
+ content=[
+ ImageContent(type="image", data=encoded, mimeType="image/webp")
+ ]
+ )
+
+ return results()
+
+ monkeypatch.setattr(tool_image_cache, "_cache_dir", str(tmp_path / "tool-cache"))
+ original_prepare = runner_module.prepare_image_source
+ captured_options = []
+
+ async def capture_prepare(image_ref, **kwargs):
+ captured_options.append(kwargs["options"])
+ return await original_prepare(image_ref, **kwargs)
+
+ monkeypatch.setattr(runner_module, "prepare_image_source", capture_prepare)
+ provider = MockProvider()
+ provider.provider_settings = {
+ "image_compress_options": {
+ "max_size": 512,
+ "quality": 42,
+ "max_encoded_bytes": 2 * 1024 * 1024,
+ }
+ }
+ provider.max_calls_before_normal_response = 1
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=ImageExecutor,
+ agent_hooks=mock_hooks,
+ image_media_store=ImageMediaStore(tmp_path / "media"),
+ )
+ async for _ in runner.step_until_done(3):
+ pass
+ images = [
+ part
+ for message in runner.run_context.messages
+ if isinstance(message.content, list)
+ for part in message.content
+ if isinstance(part, ImageMediaRefPart)
+ ]
+ assert len(images) == 1
+ assert images[0].mime_type == "image/webp"
+ assert images[0].image_id.endswith("call_123_0.webp")
+ assert captured_options[0].max_size == 512
+ assert captured_options[0].quality == 42
+ assert captured_options[0].max_encoded_bytes == 2 * 1024 * 1024
+ assert provider.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_new_media_refs_do_not_rewrite_existing_inline_history(
+ runner, mock_tool_executor, mock_hooks, tmp_path
+):
+ import base64
+ import io
+
+ from PIL import Image
+
+ from astrbot.core.agent.message import ImageMediaRefPart
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ output = io.BytesIO()
+ with Image.new("RGB", (10, 8), (10, 20, 30)) as image:
+ image.save(output, "PNG")
+ uri = "data:image/png;base64," + base64.b64encode(output.getvalue()).decode()
+ old_history = [
+ {"role": "user", "content": [{"type": "image_url", "image_url": {"url": uri}}]},
+ {"role": "assistant", "content": "Old image"},
+ ]
+ request = ProviderRequest(
+ prompt="new image", image_urls=[uri], contexts=old_history
+ )
+ provider = MockProvider()
+ provider.text_chat = AsyncMock(
+ return_value=LLMResponse(role="assistant", completion_text="ok")
+ )
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ image_media_store=ImageMediaStore(tmp_path / "media"),
+ )
+ assert runner.run_context.messages[0].content[0].image_url.url == uri
+ assert isinstance(runner.run_context.messages[-1].content[-1], ImageMediaRefPart)
+ async for _ in runner._iter_llm_responses():
+ pass
+ sent = provider.text_chat.await_args.kwargs["contexts"]
+ assert sent[0].content[0].image_url.url == uri
+ assert sent[-1].content[-1].image_url.url == uri
+ assert isinstance(runner.run_context.messages[-1].content[-1], ImageMediaRefPart)
+ assert old_history[0]["content"][0]["image_url"]["url"] == uri
+
+
+@pytest.mark.asyncio
+async def test_runner_uses_default_durable_media_store_for_new_images(
+ runner, mock_tool_executor, mock_hooks, tmp_path, monkeypatch
+):
+ import base64
+ import io
+
+ from PIL import Image
+
+ from astrbot.core.agent.message import ImageMediaRefPart
+
+ output = io.BytesIO()
+ with Image.new("RGB", (10, 8), (10, 20, 30)) as image:
+ image.save(output, "PNG")
+ uri = "data:image/png;base64," + base64.b64encode(output.getvalue()).decode()
+ monkeypatch.setattr(
+ "astrbot.core.agent.runners.tool_loop_agent_runner.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ request = ProviderRequest(prompt="new image", image_urls=[uri])
+
+ await runner.reset(
+ provider=MockProvider(),
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ )
+
+ image_part = runner.run_context.messages[-1].content[-1]
+ assert isinstance(image_part, ImageMediaRefPart)
+ assert image_part.image_id is None
+ assert (tmp_path / "media" / f"{image_part.media_id}.bin").exists()
+
+
+@pytest.mark.asyncio
+async def test_context_selection_does_not_open_out_of_window_images(
+ runner, mock_tool_executor, mock_hooks, tmp_path, monkeypatch
+):
+ from PIL import Image
+
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ image_path = tmp_path / "image.png"
+ Image.new("RGB", (8, 8), "green").save(image_path)
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(image_path.read_bytes())
+ missing = {**ref.model_dump(), "media_id": "f" * 64, "byte_size": 100_000_000}
+ request = ProviderRequest(
+ prompt="current question",
+ contexts=[
+ {"role": "user", "content": [missing]},
+ {"role": "assistant", "content": "old answer"},
+ {"role": "user", "content": "another old question"},
+ {"role": "assistant", "content": "another old answer"},
+ {"role": "user", "content": [ref.model_dump()]},
+ {"role": "assistant", "content": "recent answer"},
+ ],
+ )
+ provider = MockProvider()
+ provider.text_chat = AsyncMock(
+ return_value=LLMResponse(role="assistant", completion_text="ok")
+ )
+ opened = []
+ original_read = store.read
+
+ def read(reference, allowed_ids):
+ opened.append(reference.media_id)
+ return original_read(reference, allowed_ids)
+
+ monkeypatch.setattr(store, "read", read)
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ image_media_store=store,
+ enforce_max_turns=2,
+ )
+ async for _ in runner.step():
+ pass
+ assert opened == [ref.media_id]
+ assert provider.text_chat.await_count == 1
+ assert request.contexts[0]["content"][0] == missing
+
+
@pytest.mark.asyncio
async def test_fallback_provider_used_when_primary_raises(
runner, provider_request, mock_tool_executor, mock_hooks
@@ -1656,9 +1969,19 @@ async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
-async def test_skills_like_requery_passes_extra_user_content_parts(streaming):
+async def test_skills_like_requery_passes_extra_user_content_parts(streaming, tmp_path):
"""skills-like 模式 re-query 时应传递 extra_user_content_parts(如 image_caption)"""
+ from PIL import Image
+
from astrbot.core.agent.message import TextPart
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ image_path = tmp_path / "image.png"
+ Image.new("RGB", (8, 8), "red").save(image_path)
+ image_bytes = image_path.read_bytes()
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(image_bytes, detail="high", image_id="requery-image")
+ historical_image = {"role": "user", "content": [ref.model_dump()]}
captured_kwargs = {}
@@ -1706,7 +2029,7 @@ async def text_chat(self, **kwargs) -> LLMResponse:
req = ProviderRequest(
prompt="看看这张图",
func_tool=tool_set,
- contexts=[],
+ contexts=[historical_image, {"role": "assistant", "content": "ack"}],
extra_user_content_parts=[caption_part],
)
@@ -1723,6 +2046,7 @@ async def text_chat(self, **kwargs) -> LLMResponse:
agent_hooks=MockHooks(),
tool_schema_mode="skills_like",
streaming=streaming,
+ image_media_store=store,
)
async for _ in runner.step():
@@ -1735,6 +2059,13 @@ async def text_chat(self, **kwargs) -> LLMResponse:
parts = captured_kwargs["extra_user_content_parts"]
assert len(parts) == 1
assert parts[0].text == "一张猫的照片"
+ import base64
+
+ sent_image = captured_kwargs["contexts"][0]["content"][0]["image_url"]
+ assert base64.b64decode(sent_image["url"].split(",", 1)[1]) == image_bytes
+ assert sent_image["detail"] == "high"
+ assert sent_image["id"] == "requery-image"
+ assert historical_image["content"][0] == ref.model_dump()
@pytest.mark.asyncio
diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py
index 3319e99f3e..88f740d5a9 100644
--- a/tests/unit/test_astr_main_agent.py
+++ b/tests/unit/test_astr_main_agent.py
@@ -20,6 +20,7 @@
from astrbot.core.config.agent_runner import resolve_context_compression_config
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.cron.manager import CronJobManager
+from astrbot.core.exceptions import ProviderRequestTooLargeError
from astrbot.core.message.components import File, Image, Plain, Reply, Video
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.platform_metadata import PlatformMetadata
@@ -30,6 +31,7 @@
from astrbot.core.skills.skill_manager import SkillInfo
from astrbot.core.star.context import Context
from astrbot.core.star.star import StarMetadata
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
@pytest.fixture
@@ -44,6 +46,27 @@ def mock_provider():
return provider
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "error",
+ [
+ MemoryError("oom"),
+ ImagePayloadTooLargeError("too large"),
+ ProviderRequestTooLargeError("413"),
+ ],
+)
+async def test_image_caption_propagates_resource_errors(monkeypatch, error):
+ req = ProviderRequest(image_urls=["image.png"])
+
+ async def fail(*_args, **_kwargs):
+ raise error
+
+ monkeypatch.setattr(ama, "_request_img_caption", fail)
+ with pytest.raises(type(error)):
+ await ama._ensure_img_caption(None, req, {}, MagicMock(), "caption")
+ assert req.image_urls == []
+
+
@pytest.fixture
def mock_context():
"""Create a mock Context."""
diff --git a/tests/unit/test_context_image_budget.py b/tests/unit/test_context_image_budget.py
new file mode 100644
index 0000000000..809001b51f
--- /dev/null
+++ b/tests/unit/test_context_image_budget.py
@@ -0,0 +1,153 @@
+"""Image-byte budgets must not change history or token accounting."""
+
+import base64
+import copy
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from PIL import Image
+
+from astrbot.core.agent.context.compressor import LLMSummaryCompressor
+from astrbot.core.agent.context.image_budget import (
+ get_image_encoded_byte_limit,
+ validate_context_image_bytes,
+)
+from astrbot.core.agent.message import Message
+from astrbot.core.exceptions import ProviderRequestTooLargeError
+from astrbot.core.utils.image_media_store import ImageMediaStore
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
+
+
+@pytest.mark.parametrize("limit", [None, True, False, 0, -1, "100"])
+def test_invalid_provider_image_limit_uses_default(limit):
+ assert (
+ get_image_encoded_byte_limit(
+ {"image_compress_options": {"max_encoded_bytes": limit}}
+ )
+ == 4 * 1024 * 1024
+ )
+
+
+def test_provider_image_limit_is_respected():
+ assert (
+ get_image_encoded_byte_limit(
+ {"image_compress_options": {"max_encoded_bytes": 16 * 1024 * 1024}}
+ )
+ == 16 * 1024 * 1024
+ )
+
+
+@pytest.mark.parametrize("as_models", [False, True])
+def test_byte_budget_does_not_rewrite_history(as_models):
+ history = [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/png;base64,AAAA"},
+ }
+ ],
+ }
+ ]
+ messages = (
+ [Message.model_validate(item) for item in history] if as_models else history
+ )
+ before = copy.deepcopy(messages)
+ assert validate_context_image_bytes(messages, 4) == 4
+ with pytest.raises(ImagePayloadTooLargeError):
+ validate_context_image_bytes(messages, 3)
+ assert messages == before
+
+
+def test_reference_size_is_checked_without_opening_media():
+ history = [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_media_ref",
+ "media_id": "a" * 64,
+ "byte_size": 12,
+ "mime_type": "image/png",
+ "version": 1,
+ }
+ ],
+ }
+ ]
+ assert validate_context_image_bytes(history, 16) == 16
+ with pytest.raises(ImagePayloadTooLargeError):
+ validate_context_image_bytes(history, 15)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("vision", [True, False])
+async def test_summary_loads_only_its_selected_images(tmp_path, monkeypatch, vision):
+ image_path = tmp_path / "old.png"
+ Image.new("RGB", (8, 8), "blue").save(image_path)
+ original = image_path.read_bytes()
+ store = ImageMediaStore(tmp_path / "media")
+ old_ref = store.put(original, detail="high", image_id="old-image")
+ messages = [
+ Message.model_validate({"role": "user", "content": [old_ref.model_dump()]}),
+ Message(role="assistant", content="old answer"),
+ Message.model_validate(
+ {
+ "role": "user",
+ "content": [{**old_ref.model_dump(), "media_id": "f" * 64}],
+ }
+ ),
+ ]
+ before = [message.model_dump() for message in messages]
+ opened = []
+ original_read = store.read
+
+ def read(ref, allowed_ids):
+ opened.append(ref.media_id)
+ return original_read(ref, allowed_ids)
+
+ monkeypatch.setattr(store, "read", read)
+ provider = SimpleNamespace(
+ provider_config={"modalities": ["text", "image"] if vision else ["text"]},
+ provider_settings={},
+ text_chat=AsyncMock(return_value=SimpleNamespace(completion_text="summary")),
+ )
+ result = await LLMSummaryCompressor(
+ provider, keep_recent_ratio=0, image_media_store=store
+ )(messages)
+ assert opened == ([old_ref.media_id] if vision else [])
+ assert result[-1] is messages[-1]
+ assert [message.model_dump() for message in messages] == before
+ if vision:
+ sent = provider.text_chat.call_args.kwargs["contexts"][0]
+ image = sent["content"][0]["image_url"]
+ assert base64.b64decode(image["url"].split(",", 1)[1]) == original
+ assert image["detail"] == "high"
+ assert image["id"] == "old-image"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "error",
+ [
+ MemoryError(),
+ ImagePayloadTooLargeError("oversize"),
+ ProviderRequestTooLargeError("413"),
+ ],
+)
+async def test_summary_resource_failure_is_not_swallowed(error):
+ provider = SimpleNamespace(
+ provider_config={"modalities": ["text"]},
+ provider_settings={},
+ text_chat=AsyncMock(side_effect=error),
+ )
+ messages = [
+ Message(role="user", content="old question"),
+ Message(role="assistant", content="old answer"),
+ Message(role="user", content="current question"),
+ ]
+ with pytest.raises(type(error)) as caught:
+ await LLMSummaryCompressor(provider, keep_recent_ratio=0)(messages)
+ assert caught.value is error
+ assert provider.text_chat.await_count == 1
diff --git a/tests/unit/test_conversation_media_api.py b/tests/unit/test_conversation_media_api.py
new file mode 100644
index 0000000000..e4b0ac96fe
--- /dev/null
+++ b/tests/unit/test_conversation_media_api.py
@@ -0,0 +1,149 @@
+import io
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from PIL import Image
+
+from astrbot.dashboard.services.conversation_service import (
+ ConversationService,
+ ConversationServiceError,
+)
+
+
+def _image_bytes() -> bytes:
+ output = io.BytesIO()
+ Image.new("RGB", (2, 2), "red").save(output, format="PNG")
+ return output.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_media_preview_requires_database_owner_and_reference(
+ tmp_path, monkeypatch
+):
+ monkeypatch.setattr(
+ "astrbot.dashboard.services.conversation_service.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ data = _image_bytes()
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ ref = ImageMediaStore(Path(tmp_path) / "media").put(data, "image/png")
+ history = json.dumps([{"content": [ref.model_dump()]}])
+ conversation = SimpleNamespace(user_id="owner", history=history)
+
+ class Db:
+ async def get_conversation_by_id(self, cid):
+ return conversation if cid == "cid" else None
+
+ service = ConversationService(Db(), SimpleNamespace(conversation_manager=None))
+ result = await service.get_conversation_media("owner", "cid", ref.media_id)
+ assert result.data == data
+
+ with pytest.raises(ConversationServiceError):
+ await service.get_conversation_media("other", "cid", ref.media_id)
+ with pytest.raises(ConversationServiceError):
+ await service.get_conversation_media("owner", "cid", "0" * 64)
+
+
+@pytest.mark.asyncio
+async def test_media_preview_does_not_cross_conversation_reference_boundary(
+ tmp_path, monkeypatch
+):
+ monkeypatch.setattr(
+ "astrbot.dashboard.services.conversation_service.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ ref = ImageMediaStore(Path(tmp_path) / "media").put(_image_bytes(), "image/png")
+ conversation = SimpleNamespace(user_id="owner", history=json.dumps([]))
+
+ class Db:
+ async def get_conversation_by_id(self, cid):
+ return conversation if cid == "different-conversation" else None
+
+ service = ConversationService(Db(), SimpleNamespace(conversation_manager=None))
+ with pytest.raises(ConversationServiceError, match="媒体不存在"):
+ await service.get_conversation_media(
+ "owner", "different-conversation", ref.media_id
+ )
+
+
+@pytest.mark.asyncio
+async def test_media_preview_missing_or_corrupt_does_not_expose_path(
+ tmp_path, monkeypatch
+):
+ monkeypatch.setattr(
+ "astrbot.dashboard.services.conversation_service.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ store = ImageMediaStore(Path(tmp_path) / "media")
+ ref = store.put(_image_bytes(), "image/png")
+ conversation = SimpleNamespace(
+ user_id="owner", history=json.dumps([{"content": [ref.model_dump()]}])
+ )
+
+ class Db:
+ async def get_conversation_by_id(self, cid):
+ return conversation
+
+ service = ConversationService(Db(), SimpleNamespace(conversation_manager=None))
+ (Path(tmp_path) / "media" / f"{ref.media_id}.bin").write_bytes(b"corrupt")
+ with pytest.raises(ConversationServiceError) as exc_info:
+ await service.get_conversation_media("owner", "cid", ref.media_id)
+ assert str(tmp_path) not in str(exc_info.value)
+
+ (Path(tmp_path) / "media" / f"{ref.media_id}.bin").unlink()
+ with pytest.raises(ConversationServiceError) as exc_info:
+ await service.get_conversation_media("owner", "cid", ref.media_id)
+ assert str(tmp_path) not in str(exc_info.value)
+
+
+@pytest.mark.asyncio
+async def test_export_materializes_refs_with_detail_and_image_id(tmp_path, monkeypatch):
+ monkeypatch.setattr(
+ "astrbot.dashboard.services.conversation_service.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ from astrbot.core.utils.image_media_store import ImageMediaStore
+
+ store = ImageMediaStore(Path(tmp_path) / "media")
+ ref = store.put(_image_bytes(), "image/png", "high")
+ ref = type(ref)(
+ ref.media_id,
+ ref.mime_type,
+ ref.width,
+ ref.height,
+ ref.byte_size,
+ "high",
+ 1,
+ "img-1",
+ )
+ conversation = SimpleNamespace(
+ user_id="owner",
+ history=json.dumps([{"role": "user", "content": [ref.model_dump()]}]),
+ platform_id="test",
+ title="title",
+ persona_id=None,
+ created_at=0,
+ updated_at=0,
+ )
+
+ class Manager:
+ async def get_conversation(self, **_kwargs):
+ return conversation
+
+ service = ConversationService(
+ SimpleNamespace(), SimpleNamespace(conversation_manager=Manager())
+ )
+ exported = await service.export_conversations(
+ {"conversations": [{"user_id": "owner", "cid": "cid"}]}
+ )
+ record = json.loads(exported.file_obj.read())
+ image = record["content"][0]["content"][0]["image_url"]
+ assert image["detail"] == "high"
+ assert image["id"] == "img-1"
diff --git a/tests/unit/test_image_media_store.py b/tests/unit/test_image_media_store.py
new file mode 100644
index 0000000000..937987c1ae
--- /dev/null
+++ b/tests/unit/test_image_media_store.py
@@ -0,0 +1,334 @@
+"""Tests for durable image object ownership and authorization."""
+
+import base64
+import io
+import json
+import os
+from concurrent.futures import ThreadPoolExecutor
+
+import pytest
+from PIL import Image
+
+from astrbot.core.utils import media_utils
+from astrbot.core.utils.image_media_store import ImageMediaStore
+
+
+def _png() -> bytes:
+ output = io.BytesIO()
+ with Image.new("RGBA", (9, 7), (20, 40, 60, 100)) as image:
+ image.save(output, "PNG")
+ return output.getvalue()
+
+
+def test_store_deduplicates_and_round_trips_exact_bytes(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ first = store.put(data, detail="high")
+ second = store.put(data, detail="low")
+
+ assert first.media_id == second.media_id
+ assert first.width == 9 and first.height == 7
+ assert store.read(first, {first.media_id}) == data
+ assert len(list((tmp_path / "media").glob("*.bin"))) == 1
+
+
+def test_shared_blob_keeps_each_reference_metadata(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ first = store.put(data, detail="low", image_id="first")
+ second = store.put(data, detail="high", image_id="second")
+ assert first.detail == "low" and first.image_id == "first"
+ assert second.detail == "high" and second.image_id == "second"
+
+
+def test_shared_blob_keeps_declared_mime_per_reference(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ first = store.put(data, mime_type="image/png")
+ second = store.put(data, mime_type="image/custom", image_id="custom")
+ assert first.mime_type == "image/png"
+ assert second.mime_type == "image/custom"
+ assert store.read(second, {second.media_id}) == data
+
+
+def test_store_rejects_unauthorized_reference_and_missing_object(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png())
+ with pytest.raises(PermissionError):
+ store.read(ref, set())
+ (tmp_path / "media" / f"{ref.media_id}.bin").unlink()
+ with pytest.raises(FileNotFoundError):
+ store.read(ref, {ref.media_id})
+
+
+def test_store_never_commits_invalid_or_partial_media(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ with pytest.raises(ValueError):
+ store.put(b"not an image")
+ assert not list((tmp_path / "media").glob("*.bin"))
+ assert not list((tmp_path / "media").glob("*.json"))
+
+
+def test_persist_inline_image_refs_rejects_oversized_payload_before_decode(
+ tmp_path, monkeypatch
+):
+ store = ImageMediaStore(tmp_path / "media")
+ monkeypatch.setattr(media_utils, "MODEL_IMAGE_MAX_INPUT_BYTES", 4)
+ encoded = base64.b64encode(b"12345").decode("ascii")
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError, match="input exceeds"):
+ from astrbot.core.utils.image_media_store import persist_inline_image_refs
+
+ persist_inline_image_refs(
+ [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/png;base64,{encoded}"},
+ }
+ ],
+ }
+ ],
+ store,
+ )
+
+ assert not list((tmp_path / "media").glob("*"))
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("runtime_message", [False, True])
+async def test_reference_materialization_preserves_history_and_bytes(
+ tmp_path, runtime_message
+):
+ import base64
+
+ from astrbot.core.agent.message import Message
+ from astrbot.core.utils.image_media_store import materialize_image_media_refs
+
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ ref = store.put(data, detail="high")
+ history = [{"role": "user", "content": [ref.model_dump()]}]
+ messages = (
+ [Message.model_validate(item) for item in history]
+ if runtime_message
+ else history
+ )
+ result = await materialize_image_media_refs(messages, store)
+ dumped = result[0].model_dump() if runtime_message else result[0]
+ image = dumped["content"][0]["image_url"]
+ assert base64.b64decode(image["url"].split(",", 1)[1]) == data
+ assert image["detail"] == "high"
+ assert history[0]["content"][0]["type"] == "image_media_ref"
+ if runtime_message:
+ assert messages[0].content[0].type == "image_media_ref"
+
+
+@pytest.mark.asyncio
+async def test_unselected_reference_is_not_read(tmp_path, monkeypatch):
+ from astrbot.core.utils.image_media_store import materialize_image_media_refs
+
+ store = ImageMediaStore(tmp_path / "media")
+ store.put(_png())
+
+ def fail_read(*args, **kwargs):
+ raise AssertionError("An unselected image must not be opened")
+
+ monkeypatch.setattr(store, "read", fail_read)
+ selected = [{"role": "user", "content": "Only this text is in the active window"}]
+ assert await materialize_image_media_refs(selected, store) == selected
+
+
+@pytest.mark.asyncio
+async def test_missing_reference_has_bounded_placeholder(tmp_path):
+ from astrbot.core.utils.image_media_store import materialize_image_media_refs
+
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png())
+ (store.root / f"{ref.media_id}.bin").unlink()
+ result = await materialize_image_media_refs(
+ [{"role": "user", "content": [ref.model_dump()]}], store
+ )
+ assert result[0]["content"] == [{"type": "text", "text": "[Image unavailable]"}]
+
+
+def test_reference_rejects_path_traversal():
+ from astrbot.core.utils.image_media_store import ImageMediaRef
+
+ with pytest.raises(ValueError):
+ ImageMediaRef("../outside", "image/png", 1, 1, 10)
+
+
+def test_deduplicated_corrupt_object_is_rejected(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png())
+ (store.root / f"{ref.media_id}.bin").write_bytes(b"corrupt")
+ with pytest.raises(OSError):
+ store.put(_png())
+
+
+@pytest.mark.parametrize("failure_point", ["link", "fsync"])
+def test_write_failure_leaves_no_usable_reference(tmp_path, monkeypatch, failure_point):
+ import astrbot.core.utils.image_media_store as media_store
+
+ store = ImageMediaStore(tmp_path / "media")
+ original_link = os.link
+ original_fsync = os.fsync
+
+ if failure_point == "link":
+
+ def fail_link(source, target):
+ raise OSError("injected link failure")
+
+ monkeypatch.setattr(media_store.os, "link", fail_link)
+ else:
+
+ def fail_fsync(fd):
+ raise OSError("injected fsync failure")
+
+ monkeypatch.setattr(media_store.os, "fsync", fail_fsync)
+
+ with pytest.raises(OSError):
+ store.put(_png())
+ assert not list(store.root.glob("*.json"))
+ if failure_point == "fsync":
+ assert not list(store.root.iterdir())
+ assert original_link and original_fsync
+
+
+def test_metadata_replace_failure_is_repaired_by_next_put(tmp_path, monkeypatch):
+ import astrbot.core.utils.image_media_store as media_store
+
+ store = ImageMediaStore(tmp_path / "media")
+ original_link = os.link
+ failed = False
+
+ def fail_metadata_link(source, target):
+ nonlocal failed
+ if target.suffix == ".json" and not failed:
+ failed = True
+ raise OSError("injected metadata link failure")
+ return original_link(source, target)
+
+ monkeypatch.setattr(media_store.os, "link", fail_metadata_link)
+ with pytest.raises(OSError):
+ store.put(_png())
+ assert not list(store.root.glob("*.json"))
+ ref = store.put(_png(), detail="next")
+ assert store.read(ref, {ref.media_id}) == _png()
+
+
+def test_concurrent_same_bytes_puts_return_independent_references(tmp_path):
+ data = _png()
+
+ def put(detail):
+ return ImageMediaStore(tmp_path / "media").put(
+ data, detail=detail, image_id=detail
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ refs = list(executor.map(put, ["first", "second"]))
+ assert {ref.detail for ref in refs} == {"first", "second"}
+ assert {ref.image_id for ref in refs} == {"first", "second"}
+ for ref in refs:
+ assert ImageMediaStore(tmp_path / "media").read(ref, {ref.media_id}) == data
+
+
+def test_partial_reference_metadata_missing_is_not_usable(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ ref = store.put(data)
+ (store.root / f"{ref.media_id}.json").unlink()
+ with pytest.raises(OSError, match="incomplete"):
+ store.read(ref, {ref.media_id})
+
+
+@pytest.mark.parametrize("suffix", [".bin", ".json"])
+def test_symlinked_media_entries_are_rejected(tmp_path, suffix):
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png())
+ target = store.root / f"{ref.media_id}{suffix}"
+ target.unlink()
+ target.symlink_to(tmp_path / "outside")
+ with pytest.raises(OSError):
+ store.read(ref, {ref.media_id})
+
+
+def test_metadata_tampering_and_byte_size_tampering_are_rejected(tmp_path):
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png(), detail="high", image_id="original-id")
+ metadata_path = store.root / f"{ref.media_id}.json"
+ metadata = json.loads(metadata_path.read_text())
+ metadata["detail"] = "low"
+ metadata_path.write_text(json.dumps(metadata))
+ assert store.read(ref, {ref.media_id}) == _png()
+ metadata["detail"] = ref.detail
+ metadata["byte_size"] = ref.byte_size + 1
+ metadata_path.write_text(json.dumps(metadata))
+ with pytest.raises(OSError):
+ store.read(ref, {ref.media_id})
+
+
+@pytest.mark.asyncio
+async def test_materialized_provider_prefix_preserves_mime_detail_id_and_bytes(
+ tmp_path,
+):
+ import base64
+
+ from astrbot.core.utils.image_media_store import materialize_image_media_refs
+
+ store = ImageMediaStore(tmp_path / "media")
+ data = _png()
+ ref = store.put(data, mime_type="image/png", detail="high", image_id="img-7")
+ result = await materialize_image_media_refs(
+ [{"role": "user", "content": [ref.model_dump()]}], store
+ )
+ image_url = result[0]["content"][0]["image_url"]
+ assert image_url == {
+ "url": "data:image/png;base64," + base64.b64encode(data).decode(),
+ "detail": "high",
+ "id": "img-7",
+ }
+
+
+def test_shared_reference_survives_restart_and_temp_cleanup(tmp_path):
+ data = _png()
+ first = ImageMediaStore(tmp_path / "media")
+ ref = first.put(data)
+ (first.root / "orphan.tmp").write_bytes(b"orphan")
+ restarted = ImageMediaStore(tmp_path / "media")
+ assert restarted.read(ref, {ref.media_id}) == data
+ assert (restarted.root / "orphan.tmp").exists()
+
+
+def test_symlinked_store_root_is_rejected(tmp_path):
+ target = tmp_path / "target"
+ target.mkdir()
+ root = tmp_path / "media"
+ root.symlink_to(target, target_is_directory=True)
+ with pytest.raises(OSError, match="root"):
+ ImageMediaStore(root).put(_png())
+
+
+def test_memory_error_is_not_swallowed(tmp_path, monkeypatch):
+ store = ImageMediaStore(tmp_path / "media")
+ ref = store.put(_png())
+
+ def fail_read(*args, **kwargs):
+ raise MemoryError("injected")
+
+ monkeypatch.setattr(store, "read", fail_read)
+
+ async def run():
+ from astrbot.core.utils.image_media_store import materialize_image_media_refs
+
+ return await materialize_image_media_refs(
+ [{"role": "user", "content": [ref.model_dump()]}], store
+ )
+
+ with pytest.raises(MemoryError, match="injected"):
+ import asyncio
+
+ asyncio.run(run())
diff --git a/tests/unit/test_image_preparation_budget.py b/tests/unit/test_image_preparation_budget.py
new file mode 100644
index 0000000000..86aafe219e
--- /dev/null
+++ b/tests/unit/test_image_preparation_budget.py
@@ -0,0 +1,377 @@
+"""Regression tests for bounded image preparation and output ownership."""
+
+import asyncio
+import base64
+import io
+import random
+import threading
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from astrbot.core.utils import media_utils
+
+
+def test_file_base64_encoding_stream_matches_standard_library(tmp_path):
+ source = tmp_path / "payload.bin"
+ payload = random.Random(31).randbytes(1023 * 1024 + 5)
+ source.write_bytes(payload)
+
+ assert media_utils._encode_file_to_base64(source) == base64.b64encode(
+ payload
+ ).decode("ascii")
+
+
+@pytest.mark.parametrize(
+ "size,target", [((1280, 1280), (960, 960)), ((131, 197), (71, 103))]
+)
+def test_strip_resize_matches_composited_lanczos(tmp_path, size, target):
+ from PIL import ImageChops
+
+ with Image.frombytes(
+ "RGBA", size, random.Random(43).randbytes(size[0] * size[1] * 4)
+ ) as source:
+ with (
+ source.resize(target, Image.Resampling.LANCZOS) as expected,
+ media_utils._resize_alpha_in_strips(source, target) as actual,
+ ):
+ assert actual.size == expected.size
+ for color in ("black", "white"):
+ with (
+ Image.new("RGBA", target, color) as background,
+ Image.alpha_composite(background, expected) as expected_view,
+ Image.alpha_composite(background, actual) as actual_view,
+ ImageChops.difference(expected_view, actual_view) as delta,
+ ):
+ assert max(high for _, high in delta.getextrema()) <= 2
+
+
+@pytest.mark.parametrize(
+ "image_format,mode", [("PNG", "RGBA"), ("JPEG", "RGB"), ("WEBP", "RGBA")]
+)
+def test_compliant_bytes_are_preserved(tmp_path, image_format, mode):
+ path = tmp_path / "original"
+ with Image.new(mode, (32, 20)) as image:
+ image.save(path, image_format)
+ original = path.read_bytes()
+ result = media_utils._compress_image_sync(path, tmp_path, 1280, 95, True)
+ assert result is None
+ assert path.read_bytes() == original
+ assert list(tmp_path.iterdir()) == [path]
+
+
+@pytest.mark.parametrize(
+ "mode,save_options,expected_format",
+ [("RGB", {}, "JPEG"), ("RGBA", {"lossless": True}, "PNG")],
+)
+def test_oversized_static_webp_avoids_pillow_decoder(
+ tmp_path, monkeypatch, mode, save_options, expected_format
+):
+ if media_utils._get_webp_decoder() is None:
+ pytest.skip("Pillow does not expose the bundled WebP decoder")
+
+ source = tmp_path / "source.webp"
+ pixel_count = 512 * 512
+ channels = 4 if mode == "RGBA" else 3
+ with Image.frombytes(
+ mode,
+ (512, 512),
+ random.Random(17).randbytes(pixel_count * channels),
+ ) as image:
+ image.save(source, "WEBP", **save_options)
+
+ def unexpected_pillow_open(*args, **kwargs):
+ pytest.fail("Static WebP should use the preallocated decoder path")
+
+ monkeypatch.setattr(media_utils.PILImage, "open", unexpected_pillow_open)
+ output = media_utils._compress_image_sync(source, tmp_path, 64, 95, True, 20_000)
+ monkeypatch.undo()
+
+ assert output is not None
+ with Image.open(output) as prepared:
+ assert prepared.format == expected_format
+ assert max(prepared.size) <= 64
+
+
+def test_pixel_compliant_noise_fits_byte_budget(tmp_path):
+ source = tmp_path / "noise.png"
+ with Image.frombytes(
+ "RGBA", (1280, 1280), random.Random(7).randbytes(1280 * 1280 * 4)
+ ) as image:
+ image.save(source)
+ budget = 4 * 1024 * 1024
+ assert 4 * ((source.stat().st_size + 2) // 3) > budget
+ result = media_utils._compress_image_sync(source, tmp_path, 1280, 95, True, budget)
+ assert result is not None
+ assert 4 * ((Path(result).stat().st_size + 2) // 3) <= budget
+ with Image.open(result) as image:
+ assert image.mode == "RGBA"
+ assert image.width < 1280
+
+
+def test_animation_is_preserved_when_compliant_and_rejected_when_oversized(tmp_path):
+ source = tmp_path / "animated.gif"
+ frames = [Image.new("RGB", (16, 12), color) for color in ("red", "blue")]
+ try:
+ frames[0].save(
+ source,
+ format="GIF",
+ save_all=True,
+ append_images=frames[1:],
+ duration=40,
+ loop=0,
+ )
+ finally:
+ for frame in frames:
+ frame.close()
+
+ original = source.read_bytes()
+ assert media_utils._compress_image_sync(source, tmp_path, 4, 95, True) is None
+ assert source.read_bytes() == original
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError):
+ media_utils._compress_image_sync(source, tmp_path, 4, 95, True, 1)
+ assert list(tmp_path.glob("compressed_*")) == []
+
+
+def test_webp_animation_is_preserved_without_flattening(tmp_path):
+ source = tmp_path / "animated.webp"
+ frames = [Image.new("RGBA", (16, 12), color) for color in ("red", "blue")]
+ try:
+ frames[0].save(
+ source,
+ format="WEBP",
+ save_all=True,
+ append_images=frames[1:],
+ duration=40,
+ loop=0,
+ )
+ finally:
+ for frame in frames:
+ frame.close()
+
+ original = source.read_bytes()
+ assert media_utils._compress_image_sync(source, tmp_path, 4, 95, True) is None
+ assert source.read_bytes() == original
+
+ with pytest.raises(media_utils.ImagePayloadTooLargeError):
+ media_utils._compress_image_sync(source, tmp_path, 4, 95, True, 1)
+
+
+def test_screenshot_preserves_oriented_coordinates(tmp_path):
+ path = tmp_path / "oriented.jpg"
+ with Image.frombytes(
+ "RGB", (120, 80), random.Random(1).randbytes(120 * 80 * 3)
+ ) as image:
+ exif = image.getexif()
+ exif[274] = 6
+ image.save(path, quality=100, exif=exif)
+ output = media_utils._compress_image_sync(
+ path, tmp_path, 10, 80, True, 10_000, preserve_dimensions=True
+ )
+ assert output is not None
+ with Image.open(output) as image:
+ assert image.size == (80, 120)
+ assert image.getexif().get(274, 1) == 1
+
+
+@pytest.mark.parametrize("orientation", range(1, 9))
+def test_thumbnail_preserves_exif_corner_placement(tmp_path, orientation):
+ from PIL import ImageOps
+
+ source = tmp_path / "corners.jpg"
+ with Image.new("RGB", (800, 600), "black") as image:
+ image.paste("red", (0, 0, 400, 300))
+ image.paste("green", (400, 0, 800, 300))
+ image.paste("blue", (0, 300, 400, 600))
+ image.paste("white", (400, 300, 800, 600))
+ exif = image.getexif()
+ exif[274] = orientation
+ image.save(source, quality=95, exif=exif)
+ with Image.open(source) as image:
+ expected = ImageOps.exif_transpose(image)
+ expected.thumbnail((160, 160))
+ try:
+ output = media_utils._compress_image_sync(source, tmp_path, 160, 95, True)
+ assert output is not None
+ with Image.open(output) as actual:
+ assert actual.size == expected.size
+ assert actual.getexif().get(274, 1) == 1
+ for x in (actual.width // 4, actual.width * 3 // 4):
+ for y in (actual.height // 4, actual.height * 3 // 4):
+ assert (
+ max(
+ abs(a - b)
+ for a, b in zip(
+ actual.getpixel((x, y)), expected.getpixel((x, y))
+ )
+ )
+ <= 5
+ )
+ finally:
+ expected.close()
+
+
+def test_jpeg_stops_at_first_fitting_quality(tmp_path, monkeypatch):
+ path = tmp_path / "photo.jpg"
+ with Image.new("RGB", (200, 100), (20, 40, 80)) as image:
+ image.save(path, "JPEG", quality=95)
+ qualities = []
+ original_save = Image.Image.save
+
+ def save(image, target, fmt=None, **kwargs):
+ if fmt == "JPEG":
+ qualities.append(kwargs.get("quality"))
+ return original_save(image, target, fmt, **kwargs)
+
+ monkeypatch.setattr(Image.Image, "save", save)
+ output = media_utils._compress_image_sync(
+ path, tmp_path, 100, 95, True, 4 * 1024 * 1024
+ )
+ assert output is not None
+ assert qualities == [95]
+
+
+def test_cua_tries_next_jpeg_quality_without_resizing(tmp_path, monkeypatch):
+ path = tmp_path / "opaque.png"
+ with Image.frombytes(
+ "RGB", (120, 80), random.Random(23).randbytes(120 * 80 * 3)
+ ) as image:
+ image.save(path, "PNG")
+ qualities = []
+ original_save = Image.Image.save
+
+ def save(image, target, fmt=None, **kwargs):
+ if fmt == "JPEG":
+ quality = kwargs.get("quality")
+ qualities.append(quality)
+ if quality == 95:
+ original_save(image, target, fmt, **kwargs)
+ with Path(target).open("ab") as candidate:
+ candidate.write(b"x" * 20_000)
+ return
+ return original_save(image, target, fmt, **kwargs)
+
+ monkeypatch.setattr(Image.Image, "save", save)
+ output = media_utils._compress_image_sync(
+ path, tmp_path, 1, 95, True, 20_000, preserve_dimensions=True
+ )
+ assert output is not None
+ assert qualities[:2] == [95, 85]
+ with Image.open(output) as image:
+ assert image.size == (120, 80)
+
+
+def test_impossible_screenshot_budget_rejects_without_resizing(tmp_path):
+ path = tmp_path / "screenshot.png"
+ with Image.new("RGBA", (100, 60), (20, 100, 50, 127)) as image:
+ image.save(path)
+ with pytest.raises(media_utils.ImagePayloadTooLargeError):
+ media_utils._compress_image_sync(
+ path, tmp_path, 1, 95, True, 1, preserve_dimensions=True
+ )
+ assert list(tmp_path.iterdir()) == [path]
+
+
+def test_write_failure_removes_partial_candidate(tmp_path, monkeypatch):
+ source = io.BytesIO()
+ with Image.new("RGB", (100, 60)) as image:
+ image.save(source, "PNG")
+
+ def fail_write(image, path, *args, **kwargs):
+ Path(path).write_bytes(b"partial")
+ raise OSError("disk full")
+
+ monkeypatch.setattr(Image.Image, "save", fail_write)
+ with pytest.raises(OSError, match="disk full"):
+ media_utils._compress_image_sync(source.getvalue(), tmp_path, 10, 95, True)
+ assert not list(tmp_path.iterdir())
+
+
+def test_decode_failure_leaves_no_output(tmp_path):
+ with pytest.raises(OSError):
+ media_utils._compress_image_sync(b"invalid", tmp_path, 10, 95, True)
+ assert not list(tmp_path.iterdir())
+
+
+@pytest.mark.asyncio
+async def test_disabled_compression_rejects_oversize_before_read(tmp_path, monkeypatch):
+ source = tmp_path / "oversized.png"
+ with Image.new("RGB", (32, 32), "red") as image:
+ image.save(source)
+
+ def unexpected_read(path):
+ pytest.fail("Oversized source was read before checking the byte limit")
+
+ monkeypatch.setattr(Path, "read_bytes", unexpected_read)
+ with pytest.raises(media_utils.ImagePayloadTooLargeError):
+ await media_utils.prepare_image_source(
+ str(source),
+ options=media_utils.ImagePreparationOptions(
+ enabled=False, max_encoded_bytes=1
+ ),
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "failure", [media_utils.ImagePayloadTooLargeError, MemoryError, OSError, ValueError]
+)
+async def test_agent_does_not_fall_back_to_original_on_resource_error(
+ tmp_path, monkeypatch, failure
+):
+ source = tmp_path / "image.png"
+ with Image.new("RGB", (8, 8), "red") as image:
+ image.save(source)
+
+ async def fail(*args, **kwargs):
+ raise failure("bounded failure")
+
+ monkeypatch.setattr(media_utils, "compress_image", fail)
+ with pytest.raises(failure):
+ await media_utils.prepare_image_source(str(source))
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("timeout", [False, True])
+async def test_cancelled_worker_cleans_after_exit(tmp_path, monkeypatch, timeout):
+ source = tmp_path / "source"
+ source.write_bytes(b"source")
+ monkeypatch.setattr(media_utils, "IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_BYTES", 1)
+ output = tmp_path / "worker-output"
+ entered = threading.Event()
+ release = threading.Event()
+ cleaned = asyncio.Event()
+ original_unlink = Path.unlink
+
+ def unlink(path, *args, **kwargs):
+ original_unlink(path, *args, **kwargs)
+ if path == output:
+ cleaned.set()
+
+ monkeypatch.setattr(Path, "unlink", unlink)
+
+ def blocked_worker(*args, **kwargs):
+ entered.set()
+ release.wait(5)
+ output.write_bytes(b"prepared")
+ return str(output)
+
+ monkeypatch.setattr(media_utils, "_compress_image_sync", blocked_worker)
+ task = asyncio.create_task(media_utils.compress_image(str(source)))
+ try:
+ assert await asyncio.to_thread(entered.wait, 2)
+ if timeout:
+ with pytest.raises(asyncio.TimeoutError):
+ await asyncio.wait_for(task, timeout=0.01)
+ else:
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ finally:
+ release.set()
+ # A finished task can still have its cleanup callback queued on the loop.
+ await asyncio.wait_for(cleaned.wait(), timeout=5)
+ assert not output.exists()
+ assert source.read_bytes() == b"source"
diff --git a/tests/unit/test_image_screenshot_encoding.py b/tests/unit/test_image_screenshot_encoding.py
new file mode 100644
index 0000000000..cec79c5b0d
--- /dev/null
+++ b/tests/unit/test_image_screenshot_encoding.py
@@ -0,0 +1,43 @@
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+from astrbot.core.utils import media_utils
+
+
+def test_prefers_smaller_lossless_encoding_for_ui_screenshot(tmp_path):
+ source = tmp_path / "screenshot-ordinary.png"
+ with Image.new("RGB", (1920, 1080), "white") as image:
+ draw = ImageDraw.Draw(image)
+ for x in range(0, 1920, 32):
+ draw.line((x, 0, x, 1080), fill=(205, 205, 205), width=1)
+ for y in range(0, 1080, 32):
+ draw.line((0, y, 1920, y), fill=(205, 205, 205), width=1)
+ for index in range(80):
+ draw.text(
+ (20 + (index % 10) * 185, 20 + (index // 10) * 125),
+ f"UI {index:02d}",
+ fill="black",
+ )
+ image.save(source, "PNG", optimize=True)
+
+ output = media_utils._compress_image_sync(
+ source, tmp_path, 1280, 95, True, 4 * 1024 * 1024
+ )
+
+ assert output is not None
+ with Image.open(source) as original:
+ resized = original.copy()
+ resized.thumbnail((1280, 1280), Image.Resampling.LANCZOS)
+ png_candidate = tmp_path / "expected.png"
+ jpeg_candidate = tmp_path / "expected.jpg"
+ resized.save(png_candidate, "PNG", optimize=True)
+ resized.save(jpeg_candidate, "JPEG", quality=95, optimize=True)
+ # The old implementation always selected JPEG for opaque PNG input,
+ # even when the same resized pixels have a smaller PNG encoding.
+ assert png_candidate.stat().st_size < jpeg_candidate.stat().st_size
+ assert max(resized.size) <= 1280
+ assert Path(output).suffix == ".png"
+ assert Path(output).stat().st_size == png_candidate.stat().st_size
+ with Image.open(output) as image:
+ assert max(image.size) <= 1280
diff --git a/tests/unit/test_image_source_integration.py b/tests/unit/test_image_source_integration.py
new file mode 100644
index 0000000000..41623a78d8
--- /dev/null
+++ b/tests/unit/test_image_source_integration.py
@@ -0,0 +1,116 @@
+import base64
+import io
+
+import pytest
+from PIL import Image
+
+from astrbot.core.agent.message import ImageURLPart
+from astrbot.core.provider.entities import ProviderRequest
+from astrbot.core.utils.image_media_store import (
+ ImageMediaStore,
+ persist_inline_image_refs,
+)
+from astrbot.core.utils.media_utils import (
+ ImagePayloadTooLargeError,
+ ImagePreparationInput,
+ ImagePreparationOptions,
+ prepare_image_source,
+)
+
+
+def _image(size=(40, 20), image_format="PNG"):
+ output = io.BytesIO()
+ with Image.new("RGB", size, (30, 60, 90)) as image:
+ image.save(output, image_format)
+ return output.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_current_plugin_image_part_is_prepared_without_mutating_caller(tmp_path):
+ source = tmp_path / "quoted.png"
+ original = _image()
+ source.write_bytes(original)
+ part = ImageURLPart(
+ image_url=ImageURLPart.ImageURL(
+ url=str(source), id="plugin-image", detail="high"
+ )
+ ).mark_as_temp()
+
+ context = await ProviderRequest(extra_user_content_parts=[part]).assemble_context()
+ payload = context["content"][0]
+
+ assert payload["image_url"]["id"] == "plugin-image"
+ assert payload["image_url"]["detail"] == "high"
+ assert payload["_no_save"] is True
+ assert payload["image_url"]["url"].startswith("data:image/png;base64,")
+ assert part.image_url.url == str(source)
+
+
+@pytest.mark.asyncio
+async def test_quoted_user_path_uses_configured_encoded_limit(tmp_path):
+ source = tmp_path / "quoted.png"
+ source.write_bytes(_image())
+ options = ImagePreparationOptions(max_encoded_bytes=1)
+
+ with pytest.raises(ImagePayloadTooLargeError):
+ await prepare_image_source(str(source), options=options)
+
+
+@pytest.mark.asyncio
+async def test_cua_dimensions_are_explicit_and_normal_tool_can_resize(tmp_path):
+ source = tmp_path / "tool.png"
+ source.write_bytes(_image((80, 40)))
+ normal = await prepare_image_source(
+ str(source), options=ImagePreparationOptions(max_size=20)
+ )
+ cua = await prepare_image_source(
+ str(source),
+ options=ImagePreparationOptions(max_size=20, preserve_dimensions=True),
+ )
+
+ with Image.open(io.BytesIO(normal.to_bytes())) as image:
+ assert max(image.size) <= 20
+ with Image.open(io.BytesIO(cua.to_bytes())) as image:
+ assert image.size == (80, 40)
+
+
+def test_temporary_image_part_is_not_persisted(tmp_path):
+ image_bytes = _image()
+ image_url = "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii")
+ history = [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": image_url},
+ "_no_save": True,
+ }
+ ],
+ }
+ ]
+
+ stored = persist_inline_image_refs(history, ImageMediaStore(tmp_path / "media"))
+
+ assert stored == history
+ assert not (tmp_path / "media").exists()
+
+
+@pytest.mark.asyncio
+async def test_shared_preparation_input_releases_owned_source(tmp_path):
+ source = tmp_path / "plugin-source.png"
+ owned = tmp_path / "resolver-owned.tmp"
+ data = _image((32, 24))
+ source.write_bytes(data)
+ owned.write_bytes(b"temporary source")
+
+ prepared = await prepare_image_source(
+ ImagePreparationInput(
+ str(source),
+ source_kind="plugin_mcp",
+ cleanup_paths=(owned,),
+ )
+ )
+
+ assert prepared.to_bytes() == data
+ assert not owned.exists()
diff --git a/tests/unit/test_image_source_preparation.py b/tests/unit/test_image_source_preparation.py
new file mode 100644
index 0000000000..70de889f46
--- /dev/null
+++ b/tests/unit/test_image_source_preparation.py
@@ -0,0 +1,137 @@
+import asyncio
+import base64
+import io
+import threading
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from astrbot.core.agent.message import ImageURLPart
+from astrbot.core.provider.entities import ProviderRequest
+from astrbot.core.utils import media_utils
+
+
+def _png() -> bytes:
+ stream = io.BytesIO()
+ with Image.new("RGBA", (8, 6), (20, 40, 60, 128)) as image:
+ image.save(stream, "PNG")
+ return stream.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_prepare_image_source_accepts_all_reference_forms(tmp_path, monkeypatch):
+ data = _png()
+ source = tmp_path / "image.png"
+ source.write_bytes(data)
+ monkeypatch.chdir(tmp_path)
+
+ class Handler(SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ encoded = base64.b64encode(data).decode()
+ refs = [
+ str(source),
+ f"http://127.0.0.1:{server.server_port}/image.png",
+ f"data:image/png;base64,{encoded}",
+ f"base64://{encoded}",
+ encoded,
+ ]
+ for ref in refs:
+ result = await media_utils.prepare_image_source(ref)
+ assert result.mime_type == "image/png"
+ assert result.to_bytes() == data
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+@pytest.mark.asyncio
+async def test_prepare_cancel_keeps_resolver_source_until_worker_exits(
+ tmp_path, monkeypatch
+):
+ entered = threading.Event()
+ release = threading.Event()
+ source_seen = {}
+ original = media_utils._compress_image_sync
+
+ def blocked(source, *args, **kwargs):
+ source_seen["path"] = Path(source) if isinstance(source, (str, Path)) else None
+ entered.set()
+ release.wait(5)
+ return original(source, *args, **kwargs)
+
+ monkeypatch.setattr(media_utils, "_compress_image_sync", blocked)
+ encoded = base64.b64encode(_png()).decode()
+ task = asyncio.create_task(
+ media_utils.prepare_image_source(
+ f"data:image/png;base64,{encoded}",
+ options=media_utils.ImagePreparationOptions(max_size=2),
+ )
+ )
+ assert await asyncio.to_thread(entered.wait, 2)
+ assert source_seen["path"] is not None
+ assert source_seen["path"].exists()
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert source_seen["path"].exists()
+ release.set()
+ await asyncio.sleep(0.2)
+ assert not source_seen["path"].exists()
+
+
+@pytest.mark.asyncio
+async def test_prepare_bytes_write_failure_cleans_owned_source(monkeypatch, tmp_path):
+ owned = tmp_path / "owned.bin"
+
+ monkeypatch.setattr(media_utils, "_temp_media_path", lambda *_args: owned)
+
+ original_write_bytes = Path.write_bytes
+
+ def fail_write(path, data):
+ original_write_bytes(path, b"partial")
+ raise OSError("disk full")
+
+ monkeypatch.setattr(Path, "write_bytes", fail_write)
+ with pytest.raises(OSError, match="disk full"):
+ await media_utils.prepare_image_source(_png())
+ assert not owned.exists()
+
+
+@pytest.mark.asyncio
+async def test_provider_request_passes_preparation_options():
+ data = base64.b64encode(_png()).decode()
+ request = ProviderRequest(
+ image_urls=[f"data:image/png;base64,{data}"],
+ image_preparation_options=media_utils.ImagePreparationOptions(
+ enabled=False, max_encoded_bytes=None
+ ),
+ )
+ context = await request.assemble_context()
+ assert context["content"][1]["image_url"]["url"].endswith(data)
+
+
+@pytest.mark.asyncio
+async def test_extra_image_part_prepares_copy_and_preserves_metadata(tmp_path):
+ data = _png()
+ source = tmp_path / "plugin.png"
+ source.write_bytes(data)
+ part = ImageURLPart(
+ image_url=ImageURLPart.ImageURL(url=str(source), id="plugin-id", detail="high")
+ ).mark_as_temp()
+ request = ProviderRequest(extra_user_content_parts=[part])
+
+ context = await request.assemble_context()
+ payload = context["content"][0]
+ assert payload["image_url"]["id"] == "plugin-id"
+ assert payload["image_url"]["detail"] == "high"
+ assert payload["_no_save"] is True
+ assert payload["image_url"]["url"].startswith("data:image/png;base64,")
+ assert part.image_url.url == str(source)
diff --git a/tests/unit/test_provider_image_references.py b/tests/unit/test_provider_image_references.py
new file mode 100644
index 0000000000..0089dc9f36
--- /dev/null
+++ b/tests/unit/test_provider_image_references.py
@@ -0,0 +1,191 @@
+import base64
+from copy import deepcopy
+
+import pytest
+from PIL import Image
+
+from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
+from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI
+from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
+from astrbot.core.utils.image_media_store import ImageMediaStore
+from astrbot.core.utils.media_utils import ImagePayloadTooLargeError
+
+
+@pytest.fixture
+def image_context(tmp_path, monkeypatch):
+ with Image.new("RGB", (4, 3), "red") as image:
+ source = tmp_path / "source.png"
+ image.save(source, "PNG")
+ ref = ImageMediaStore(tmp_path / "media").put(source.read_bytes())
+ context = [{"role": "user", "content": [ref.model_dump()]}]
+ for module in ("openai_source", "anthropic_source", "gemini_source"):
+ monkeypatch.setattr(
+ f"astrbot.core.provider.sources.{module}.get_astrbot_data_path",
+ lambda tmp_path=tmp_path: str(tmp_path),
+ )
+ return context
+
+
+def _bare(adapter):
+ instance = object.__new__(adapter)
+ instance.provider_config = {}
+ instance.provider_settings = {}
+ instance.model_name = "test-model"
+ instance.client = type(
+ "Client", (), {"base_url": type("URL", (), {"host": ""})()}
+ )()
+ return instance
+
+
+@pytest.mark.asyncio
+async def test_openai_payload_materializes_reference_without_mutating(image_context):
+ adapter = _bare(ProviderOpenAIOfficial)
+ payload, _ = await adapter._prepare_chat_payload(None, contexts=image_context)
+ assert payload["messages"][0]["content"][0]["image_url"]["url"].startswith(
+ "data:image/"
+ )
+ assert image_context[0]["content"][0]["type"] == "image_media_ref"
+
+
+@pytest.mark.asyncio
+async def test_oversized_reference_fails_before_materialization(image_context):
+ oversized = deepcopy(image_context)
+ oversized[0]["content"][0]["byte_size"] = 10_000_000
+ adapter = _bare(ProviderOpenAIOfficial)
+ with pytest.raises(ImagePayloadTooLargeError):
+ await adapter._prepare_chat_payload(None, contexts=oversized)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("adapter_type", [ProviderOpenAIOfficial, ProviderGoogleGenAI])
+async def test_provider_settings_control_reference_budget(adapter_type, image_context):
+ adapter = _bare(adapter_type)
+ adapter.provider_settings = {"image_compress_options": {"max_encoded_bytes": 1}}
+ with pytest.raises(ImagePayloadTooLargeError):
+ if adapter_type is ProviderOpenAIOfficial:
+ await adapter._prepare_chat_payload(None, contexts=image_context)
+ else:
+ await adapter._prepare_conversation({"messages": image_context})
+
+ adapter.provider_settings = {
+ "image_compress_options": {"max_encoded_bytes": 1024 * 1024}
+ }
+ if adapter_type is ProviderOpenAIOfficial:
+ payload, _ = await adapter._prepare_chat_payload(None, contexts=image_context)
+ assert payload["messages"][0]["content"][0]["image_url"]
+ else:
+ contents = await adapter._prepare_conversation({"messages": image_context})
+ assert contents[0].parts[0].inline_data is not None
+
+
+@pytest.mark.asyncio
+async def test_text_only_modality_scrubs_missing_oversized_reference_without_read(
+ image_context,
+):
+ missing = deepcopy(image_context)
+ missing[0]["content"][0]["media_id"] = "f" * 64
+ missing[0]["content"][0]["byte_size"] = 100_000_000
+ adapter = _bare(ProviderOpenAIOfficial)
+ adapter.provider_config = {"modalities": ["text"]}
+ payload, _ = await adapter._prepare_chat_payload(None, contexts=missing)
+ assert payload["messages"][0]["content"] == [{"type": "text", "text": "[Image]"}]
+
+
+@pytest.mark.asyncio
+async def test_anthropic_text_chat_materializes_reference_without_mutating(
+ image_context,
+):
+ adapter = _bare(ProviderAnthropic)
+ captured = {}
+
+ async def query(payloads, tools, **kwargs):
+ captured.update(payloads)
+ raise RuntimeError("stop after capture")
+
+ adapter._query = query
+ with pytest.raises(RuntimeError, match="stop after capture"):
+ await adapter.text_chat(contexts=deepcopy(image_context))
+ image = captured["messages"][0]["content"][0]
+ assert image["source"]["type"] == "base64"
+ assert image_context[0]["content"][0]["type"] == "image_media_ref"
+
+
+@pytest.mark.asyncio
+async def test_anthropic_provider_settings_control_reference_budget(image_context):
+ adapter = _bare(ProviderAnthropic)
+ adapter.provider_settings = {"image_compress_options": {"max_encoded_bytes": 1}}
+ with pytest.raises(ImagePayloadTooLargeError):
+ await adapter.text_chat(contexts=image_context)
+
+
+@pytest.mark.asyncio
+async def test_gemini_conversation_materializes_reference(image_context):
+ adapter = _bare(ProviderGoogleGenAI)
+ contents = await adapter._prepare_conversation({"messages": image_context})
+ assert contents[0].parts[0].inline_data is not None
+ assert image_context[0]["content"][0]["type"] == "image_media_ref"
+
+
+def test_anthropic_payload_detects_data_uri_from_prefix_without_copying_payload(
+ monkeypatch,
+):
+ adapter = _bare(ProviderAnthropic)
+ image_bytes = b"\x89PNG\r\n\x1a\n" + b"x" * 4096
+ encoded = base64.b64encode(image_bytes).decode()
+ detected_prefixes = []
+
+ def detect_mime(prefix: bytes) -> str:
+ detected_prefixes.append(prefix)
+ return "image/png"
+
+ monkeypatch.setattr(adapter, "_detect_image_mime_type", detect_mime)
+ _, messages = adapter._prepare_payload(
+ [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/png;base64,{encoded}"},
+ }
+ ],
+ }
+ ]
+ )
+
+ assert detected_prefixes == [image_bytes[:48]]
+ assert messages[0]["content"][0]["source"]["data"] == encoded
+
+
+@pytest.mark.asyncio
+async def test_gemini_data_uri_skips_shared_resolver(monkeypatch):
+ adapter = _bare(ProviderGoogleGenAI)
+ image_bytes = b"\x89PNG\r\n\x1a\n" + b"payload"
+ encoded = base64.b64encode(image_bytes).decode()
+
+ async def fail_if_resolved(*_args, **_kwargs):
+ raise AssertionError("data URLs should not go through the shared resolver")
+
+ monkeypatch.setattr(
+ "astrbot.core.provider.sources.gemini_source.resolve_media_ref_to_base64_data",
+ fail_if_resolved,
+ )
+ contents = await adapter._prepare_conversation(
+ {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/png;base64,{encoded}"},
+ }
+ ],
+ }
+ ]
+ }
+ )
+
+ assert contents[0].parts is not None
+ assert contents[0].parts[0].inline_data.data == image_bytes
+ assert contents[0].parts[0].inline_data.mime_type == "image/png"
diff --git a/tests/unit/test_provider_request_retry.py b/tests/unit/test_provider_request_retry.py
new file mode 100644
index 0000000000..95e3fc8832
--- /dev/null
+++ b/tests/unit/test_provider_request_retry.py
@@ -0,0 +1,40 @@
+import pytest
+
+from astrbot.core.exceptions import ProviderRequestTooLargeError
+from astrbot.core.provider.sources.request_retry import retry_provider_request
+
+
+class _Response:
+ status_code = 413
+
+
+class _RequestTooLargeError(Exception):
+ response = _Response()
+
+
+@pytest.mark.asyncio
+async def test_memory_error_is_not_retried():
+ calls = 0
+
+ async def request():
+ nonlocal calls
+ calls += 1
+ raise MemoryError("allocation failed")
+
+ with pytest.raises(MemoryError):
+ await retry_provider_request("test", request, max_attempts=5)
+ assert calls == 1
+
+
+@pytest.mark.asyncio
+async def test_http_413_becomes_typed_request_size_error_without_retry():
+ calls = 0
+
+ async def request():
+ nonlocal calls
+ calls += 1
+ raise _RequestTooLargeError("payload too large")
+
+ with pytest.raises(ProviderRequestTooLargeError, match="HTTP 413"):
+ await retry_provider_request("test", request, max_attempts=5)
+ assert calls == 1