feat(multimodal): re-implement audio/video support on main

Re-implements PR #2908's generalized multimodal support on the
post-FSM main branch:

- Audio input: detects WAV/MP3/OGG/FLAC via magic bytes, sends
  input_audio blocks to compatible providers, falls back to
  [audio: path] placeholder when unsupported.
- Video input: sends video_url data-URI blocks to compatible
  providers, falls back to [video: path] placeholder.
- InputLimitsConfig: count limits (images/audios/videos) and byte
  limits per media type.
- AgentDefaults: pattern-matched vision_models, audio_models,
  video_models with supports_*() helpers.
- Provider retry: strips all media types (image_url, input_audio,
  video_url) on non-transient errors and retries once.
- Feishu: extracts media tags from post messages.
- Anthropic & OpenAI Responses converters handle audio/video.

Tests: 17 new multimodal tests + existing suite passes.
This commit is contained in:
chengyongru 2026-05-20 11:47:01 +08:00
parent 1391aa3d57
commit 0e754d2591
11 changed files with 625 additions and 71 deletions

View File

@ -2,6 +2,7 @@
import base64 import base64
import mimetypes import mimetypes
import os
import platform import platform
from contextlib import suppress from contextlib import suppress
from importlib.resources import files as pkg_files from importlib.resources import files as pkg_files
@ -10,11 +11,16 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.config.schema import InputLimitsConfig
from nanobot.session.goal_state import goal_state_runtime_lines from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
audio_format_for_api,
audio_mime_compat,
current_time_str, current_time_str,
detect_audio_mime,
detect_image_mime, detect_image_mime,
truncate_text, truncate_text,
video_mime_compat,
) )
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
@ -28,11 +34,12 @@ class ContextBuilder:
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size _MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
_RUNTIME_CONTEXT_END = "[/Runtime Context]" _RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None, input_limits: InputLimitsConfig | None = None):
self.workspace = workspace self.workspace = workspace
self.timezone = timezone self.timezone = timezone
self.memory = MemoryStore(workspace) self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None) self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
self.input_limits = input_limits or InputLimitsConfig()
def build_system_prompt( def build_system_prompt(
self, self,
@ -142,6 +149,28 @@ class ContextBuilder:
return content.strip() == tpl.read_text(encoding="utf-8").strip() return content.strip() == tpl.read_text(encoding="utf-8").strip()
return False return False
@staticmethod
def _file_size_ok(p: Path, max_bytes: int) -> bool | None:
"""Check file size via stat without reading into memory.
Returns True if size is within limit, False if oversized,
None if file cannot be stat'd (caller should try read_bytes instead).
"""
try:
return os.stat(p).st_size <= max_bytes
except OSError:
return None
@staticmethod
def _encode_image_block(raw: bytes, mime: str, path: Path) -> dict[str, Any]:
"""Base64-encode file bytes into an image_url content block."""
b64 = base64.b64encode(raw).decode()
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(path)},
}
def build_messages( def build_messages(
self, self,
history: list[dict[str, Any]], history: list[dict[str, Any]],
@ -154,6 +183,9 @@ class ContextBuilder:
sender_id: str | None = None, sender_id: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None, session_metadata: Mapping[str, Any] | None = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
extra = goal_state_runtime_lines(session_metadata) extra = goal_state_runtime_lines(session_metadata)
@ -164,7 +196,12 @@ class ContextBuilder:
sender_id=sender_id, sender_id=sender_id,
supplemental_lines=extra or None, supplemental_lines=extra or None,
) )
user_content = self._build_user_content(current_message, media) user_content = self._build_user_content(
current_message, media,
supports_vision=supports_vision,
supports_audio=supports_audio,
supports_video=supports_video,
)
# Merge runtime context and user content into a single user message # Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject. # to avoid consecutive same-role messages that some providers reject.
@ -186,28 +223,171 @@ class ContextBuilder:
messages.append({"role": current_role, "content": merged}) messages.append({"role": current_role, "content": merged})
return messages return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: def _build_user_content(
"""Build user message content with optional base64-encoded images.""" self,
text: str,
media: list[str] | None,
*,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
) -> str | list[dict[str, Any]]:
"""Build user message content with optional media blocks.
Args:
text: The user text message.
media: List of file paths to media files.
supports_vision: True=model supports images, False=use placeholder,
None=unconfigured (send images as before, let
provider/retry handle degradation).
supports_audio: True=model supports native audio, False/None=skip
(channel layer already transcribed).
supports_video: True=model supports native video, False/None=use
[file: path] placeholder.
"""
if not media: if not media:
return text return text
images = [] blocks: list[dict[str, Any]] = []
notes: list[str] = []
limits = self.input_limits
# Enforce image count limit
max_images = limits.max_input_images
image_count = 0
image_media = []
non_image_media = []
for path in media: for path in media:
p = Path(path)
guessed_mime = mimetypes.guess_type(path)[0] or ""
if guessed_mime.startswith("image/"):
image_count += 1
if image_count <= max_images:
image_media.append(path)
else:
non_image_media.append(path)
if image_count > max_images:
extra = image_count - max_images
noun = "image" if extra == 1 else "images"
notes.append(
f"[Skipped {extra} {noun}: "
f"only the first {max_images} images are included]"
)
# Process images
for path in image_media:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
continue continue
raw = p.read_bytes()
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] # When explicitly marked as non-vision, downgrade to text placeholder
if not mime or not mime.startswith("image/"): if supports_vision is False:
blocks.append({"type": "text", "text": f"[image: {p}]"})
continue continue
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
if not images: size_ok = self._file_size_ok(p, limits.max_input_image_bytes)
return text if size_ok is False:
return images + [{"type": "text", "text": text}] size_mb = limits.max_input_image_bytes // (1024 * 1024)
notes.append(f"[Skipped image: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped image: unable to read ({p.name or path})]")
continue
img_mime = detect_image_mime(raw[:32]) or mimetypes.guess_type(path)[0]
if not img_mime or not img_mime.startswith("image/"):
notes.append(f"[Skipped image: unsupported or invalid image format ({p.name})]")
continue
blocks.append(self._encode_image_block(raw, img_mime, p))
# Process non-image media (audio, video, unknown)
audio_count = 0
video_count = 0
for path in non_image_media:
p = Path(path)
if not p.is_file():
continue
guessed_mime = mimetypes.guess_type(path)[0] or ""
is_audio = guessed_mime.startswith("audio/")
is_video = guessed_mime.startswith("video/")
# Pre-check file size via stat to avoid reading oversized files into memory.
# Determine the relevant byte limit based on detected media type.
_size_limit = 0
if is_audio or is_video:
_size_limit = limits.max_input_audio_bytes if is_audio else limits.max_input_video_bytes
_stat_size_ok = self._file_size_ok(p, _size_limit) if _size_limit else None
if _stat_size_ok is False:
size_mb = _size_limit // (1024 * 1024)
label = "audio" if is_audio else "video"
notes.append(f"[Skipped {label}: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped file: unable to read ({p.name or path})]")
continue
# Audio detection: by magic bytes or by filename
# Always pass filename so fallback can match when magic bytes fail
audio_mime = detect_audio_mime(raw[:32], filename=path)
if audio_mime or is_audio:
if supports_audio is True and audio_mime_compat(audio_mime):
audio_count += 1
if audio_count > limits.max_input_audios:
if audio_count == limits.max_input_audios + 1:
notes.append(
f"[Skipped audio: only {limits.max_input_audios} audio file(s) allowed]"
)
continue
if len(raw) > limits.max_input_audio_bytes:
size_mb = limits.max_input_audio_bytes // (1024 * 1024)
notes.append(f"[Skipped audio: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "input_audio",
"input_audio": {"data": b64, "format": audio_format_for_api(audio_mime)},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[audio: {p}]"})
continue
# Video detection (already classified above)
if is_video:
if supports_video is True and video_mime_compat(guessed_mime):
video_count += 1
if video_count > limits.max_input_videos:
if video_count == limits.max_input_videos + 1:
notes.append(
f"[Skipped video: only {limits.max_input_videos} video file(s) allowed]"
)
continue
if len(raw) > limits.max_input_video_bytes:
size_mb = limits.max_input_video_bytes // (1024 * 1024)
notes.append(f"[Skipped video: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "video_url",
"video_url": {"url": f"data:{guessed_mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[video: {p}]"})
continue
# Unknown files are silently ignored (preserves pre-multimodal behaviour)
continue
note_text = "\n".join(notes).strip()
text_block = text if not note_text else (f"{note_text}\n\n{text}" if text else note_text)
if not blocks:
return text_block
return blocks + [{"type": "text", "text": text_block}]

View File

@ -190,6 +190,10 @@ class AgentLoop:
model_preset: str | None = None, model_preset: str | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None,
input_limits: Any = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@ -227,6 +231,10 @@ class AgentLoop:
self.tools_config = _tc self.tools_config = _tc
self.web_config = _tc.web self.web_config = _tc.web
self.exec_config = _tc.exec self.exec_config = _tc.exec
self.input_limits = input_limits or _tc.input_limits
self._supports_vision = supports_vision
self._supports_audio = supports_audio
self._supports_video = supports_video
self._image_generation_provider_configs = dict(image_generation_provider_configs or {}) self._image_generation_provider_configs = dict(image_generation_provider_configs or {})
if ( if (
image_generation_provider_config is not None image_generation_provider_config is not None
@ -240,7 +248,7 @@ class AgentLoop:
self._pending_turn_latency_ms: dict[str, int] = {} self._pending_turn_latency_ms: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or [] self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills, input_limits=self.input_limits)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator( self._webui_turns = WebuiTurnCoordinator(
bus=self.bus, bus=self.bus,
@ -366,6 +374,10 @@ class AgentLoop:
model_preset=defaults.model_preset, model_preset=defaults.model_preset,
provider_snapshot_loader=provider_snapshot_loader, provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader,
input_limits=config.tools.input_limits,
supports_vision=defaults.supports_vision(defaults.model),
supports_audio=defaults.supports_audio(defaults.model),
supports_video=defaults.supports_video(defaults.model),
**extra, **extra,
) )
@ -594,6 +606,9 @@ class AgentLoop:
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending_summary, session_summary=pending_summary,
session_metadata=session.metadata, session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
) )
async def _dispatch_command_inline( async def _dispatch_command_inline(
@ -1059,6 +1074,9 @@ class AgentLoop:
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending, session_summary=pending,
session_metadata=session.metadata, session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
) )
t_wall = time.time() t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop( final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(

View File

@ -172,19 +172,22 @@ def _extract_element_content(element: dict) -> list[str]:
return parts return parts
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: def _extract_post_content(content_json: dict) -> tuple[str, list[str], list[dict]]:
"""Extract text and image keys from Feishu post (rich text) message. """Extract text and media info from Feishu post (rich text) message.
Handles three payload shapes: Handles three payload shapes:
- Direct: {"title": "...", "content": [[...]]} - Direct: {"title": "...", "content": [[...]]}
- Localized: {"zh_cn": {"title": "...", "content": [...]}} - Localized: {"zh_cn": {"title": "...", "content": [...]}}
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}} - Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
Returns (text, image_keys, media_items) where media_items is a list of
{"tag": "media", "file_key": "..."} dicts for video/file attachments.
""" """
def _parse_block(block: dict) -> tuple[str | None, list[str]]: def _parse_block(block: dict) -> tuple[str | None, list[str], list[dict]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list): if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, [] return None, [], []
texts, images = [], [] texts, images, medias = [], [], []
if title := block.get("title"): if title := block.get("title"):
texts.append(title) texts.append(title)
for row in block["content"]: for row in block["content"]:
@ -204,43 +207,36 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
texts.append(f"\n```{lang}\n{code_text}\n```\n") texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and (key := el.get("image_key")): elif tag == "img" and (key := el.get("image_key")):
images.append(key) images.append(key)
return (" ".join(texts).strip() or None), images elif tag == "media" and el.get("file_key"):
medias.append({"tag": "media", "file_key": el["file_key"]})
return (" ".join(texts).strip() or None), images, medias
# Unwrap optional {"post": ...} envelope # Unwrap optional {"post": ...} envelope
root = content_json root = content_json
if isinstance(root, dict) and isinstance(root.get("post"), dict): if isinstance(root, dict) and isinstance(root.get("post"), dict):
root = root["post"] root = root["post"]
if not isinstance(root, dict): if not isinstance(root, dict):
return "", [] return "", [], []
# Direct format # Direct format
if "content" in root: if "content" in root:
text, imgs = _parse_block(root) text, imgs, medias = _parse_block(root)
if text or imgs: if text or imgs or medias:
return text or "", imgs return text or "", imgs, medias
# Localized: prefer known locales, then fall back to any dict child # Localized: prefer known locales, then fall back to any dict child
for key in ("zh_cn", "en_us", "ja_jp"): for key in ("zh_cn", "en_us", "ja_jp"):
if key in root: if key in root:
text, imgs = _parse_block(root[key]) text, imgs, medias = _parse_block(root[key])
if text or imgs: if text or imgs or medias:
return text or "", imgs return text or "", imgs, medias
for val in root.values(): for val in root.values():
if isinstance(val, dict): if isinstance(val, dict):
text, imgs = _parse_block(val) text, imgs, medias = _parse_block(val)
if text or imgs: if text or imgs or medias:
return text or "", imgs return text or "", imgs, medias
return "", [] return "", [], []
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
class FeishuConfig(Base): class FeishuConfig(Base):
@ -1156,7 +1152,7 @@ class FeishuChannel(BaseChannel):
if msg_type == "text": if msg_type == "text":
text = content_json.get("text", "").strip() text = content_json.get("text", "").strip()
elif msg_type == "post": elif msg_type == "post":
text, _ = _extract_post_content(content_json) text, _, _ = _extract_post_content(content_json)
text = text.strip() text = text.strip()
else: else:
text = "" text = ""
@ -1751,7 +1747,7 @@ class FeishuChannel(BaseChannel):
content_parts.append(text) content_parts.append(text)
elif msg_type == "post": elif msg_type == "post":
text, image_keys = _extract_post_content(content_json) text, image_keys, media_items = _extract_post_content(content_json)
if text: if text:
content_parts.append(text) content_parts.append(text)
# Download images embedded in post # Download images embedded in post
@ -1762,6 +1758,14 @@ class FeishuChannel(BaseChannel):
if file_path: if file_path:
media_paths.append(file_path) media_paths.append(file_path)
content_parts.append(content_text) content_parts.append(content_text)
# Download media (video/file) embedded in post
for media_item in media_items:
file_path, content_text = await self._download_and_save_media(
"media", media_item, message_id
)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("image", "audio", "file", "media"): elif msg_type in ("image", "audio", "file", "media"):
file_path, content_text = await self._download_and_save_media( file_path, content_text = await self._download_and_save_media(

View File

@ -155,8 +155,35 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("consolidationRatio"), validation_alias=AliasChoices("consolidationRatio"),
serialization_alias="consolidationRatio", serialization_alias="consolidationRatio",
) # Consolidation target ratio (0.5 = 50% of budget retained after compression) ) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
vision_models: list[str] = Field(default_factory=list) # Models that support image input
audio_models: list[str] = Field(default_factory=list) # Models that support native audio input
video_models: list[str] = Field(default_factory=list) # Models that support native video input
dream: DreamConfig = Field(default_factory=DreamConfig) dream: DreamConfig = Field(default_factory=DreamConfig)
@staticmethod
def _bare_model(model: str) -> str:
"""Strip provider prefix, e.g. 'openai/gpt-4o' -> 'gpt-4o'."""
return model.split("/", 1)[-1].lower() if "/" in model else model.lower()
def _supports_capability(self, model: str, patterns: list[str]) -> bool | None:
"""Check if model matches any pattern. Returns None if patterns is empty."""
if not patterns:
return None
bare = self._bare_model(model)
return any(p.lower() in bare for p in patterns)
def supports_vision(self, model: str) -> bool | None:
"""Check if model supports vision. None if unconfigured."""
return self._supports_capability(model, self.vision_models)
def supports_audio(self, model: str) -> bool | None:
"""Check if model supports native audio. None if unconfigured."""
return self._supports_capability(model, self.audio_models)
def supports_video(self, model: str) -> bool | None:
"""Check if model supports native video. None if unconfigured."""
return self._supports_capability(model, self.video_models)
class AgentsConfig(Base): class AgentsConfig(Base):
"""Agent configuration.""" """Agent configuration."""
@ -258,6 +285,17 @@ class MCPServerConfig(Base):
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
class InputLimitsConfig(Base):
"""Limits for user-provided multimodal inputs."""
max_input_images: int = 3
max_input_image_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_audios: int = 1
max_input_audio_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_videos: int = 1
max_input_video_bytes: int = 20 * 1024 * 1024 # 20 MB
def _lazy_default(module_path: str, class_name: str) -> Any: def _lazy_default(module_path: str, class_name: str) -> Any:
"""Deferred import helper for ToolsConfig default factories.""" """Deferred import helper for ToolsConfig default factories."""
import importlib import importlib
@ -279,6 +317,7 @@ class ToolsConfig(Base):
image_generation: ImageGenerationToolConfig = Field( image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
) )
input_limits: InputLimitsConfig = Field(default_factory=InputLimitsConfig)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory restrict_to_workspace: bool = False # restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)

View File

@ -212,7 +212,7 @@ class AnthropicProvider(LLMProvider):
@staticmethod @staticmethod
def _convert_user_content(content: Any) -> Any: def _convert_user_content(content: Any) -> Any:
"""Convert user message content, translating image_url blocks.""" """Convert user message content, translating image_url and input_audio blocks."""
if isinstance(content, str) or content is None: if isinstance(content, str) or content is None:
return content or "(empty)" return content or "(empty)"
if not isinstance(content, list): if not isinstance(content, list):
@ -228,6 +228,14 @@ class AnthropicProvider(LLMProvider):
if converted: if converted:
result.append(converted) result.append(converted)
continue continue
if item.get("type") == "input_audio":
# Anthropic doesn't support native audio → text placeholder
result.append(LLMProvider._media_placeholder("input_audio", item))
continue
if item.get("type") == "video_url":
# Anthropic doesn't support native video → text placeholder
result.append(LLMProvider._media_placeholder("video_url", item))
continue
result.append(item) result.append(item)
return result or "(empty)" return result or "(empty)"

View File

@ -13,8 +13,6 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.utils.helpers import image_placeholder_text
@dataclass @dataclass
class ToolCallRequest: class ToolCallRequest:
@ -439,9 +437,23 @@ class LLMProvider(ABC):
return merged return merged
_MEDIA_LABEL_MAP = {"image_url": "image", "input_audio": "audio", "video_url": "video"}
_STRIP_MEDIA_TYPES = frozenset({"image_url", "input_audio", "video_url"})
@staticmethod @staticmethod
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None: def _media_placeholder(btype: str, block: dict[str, Any]) -> dict[str, str]:
"""Replace image_url blocks with text placeholder. Returns None if no images found.""" """Build a text placeholder for a media block."""
path = (block.get("_meta") or {}).get("path", "")
label = LLMProvider._MEDIA_LABEL_MAP.get(btype, "media")
text = f"[{label}: {path}]" if path else f"[{label}]"
return {"type": "text", "text": text}
@staticmethod
def _strip_media_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url, input_audio, and video_url blocks with text placeholders.
Returns None if no media blocks were found (no changes needed).
"""
found = False found = False
result = [] result = []
for msg in messages: for msg in messages:
@ -449,10 +461,8 @@ class LLMProvider(ABC):
if isinstance(content, list): if isinstance(content, list):
new_content = [] new_content = []
for b in content: for b in content:
if isinstance(b, dict) and b.get("type") == "image_url": if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
path = (b.get("_meta") or {}).get("path", "") new_content.append(LLMProvider._media_placeholder(b["type"], b))
placeholder = image_placeholder_text(path, empty="[image omitted]")
new_content.append({"type": "text", "text": placeholder})
found = True found = True
else: else:
new_content.append(b) new_content.append(b)
@ -462,8 +472,13 @@ class LLMProvider(ABC):
return result if found else None return result if found else None
@staticmethod @staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool: def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder *in-place*. """Replace image_url blocks with text placeholder. Returns None if no images found."""
return LLMProvider._strip_media_content(messages)
@staticmethod
def _strip_media_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace media blocks with text placeholder *in-place*.
Mutates the content lists of the original message dicts so that Mutates the content lists of the original message dicts so that
callers holding references to those dicts also see the stripped callers holding references to those dicts also see the stripped
@ -474,13 +489,16 @@ class LLMProvider(ABC):
content = msg.get("content") content = msg.get("content")
if isinstance(content, list): if isinstance(content, list):
for i, b in enumerate(content): for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") == "image_url": if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
path = (b.get("_meta") or {}).get("path", "") content[i] = LLMProvider._media_placeholder(b["type"], b)
placeholder = image_placeholder_text(path, empty="[image omitted]")
content[i] = {"type": "text", "text": placeholder}
found = True found = True
return found return found
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*."""
return LLMProvider._strip_media_content_inplace(messages)
async def _safe_chat(self, **kwargs: Any) -> LLMResponse: async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses.""" """Call chat() and convert unexpected exceptions to error responses."""
try: try:
@ -738,18 +756,18 @@ class LLMProvider(ABC):
identical_error_count = 1 if error_key else 0 identical_error_count = 1 if error_key else 0
if not self._is_transient_response(response): if not self._is_transient_response(response):
stripped = self._strip_image_content(original_messages) stripped = self._strip_media_content(original_messages)
if stripped is not None and stripped != kw["messages"]: if stripped is not None and stripped != kw["messages"]:
logger.warning( logger.warning(
"Non-transient LLM error with image content, retrying without images" "Non-transient LLM error with media content, retrying without media"
) )
retry_kw = dict(kw) retry_kw = dict(kw)
retry_kw["messages"] = stripped retry_kw["messages"] = stripped
result = await call(**retry_kw) result = await call(**retry_kw)
# Permanently strip images from the original messages so # Permanently strip media from the original messages so
# subsequent iterations do not repeat the error-retry cycle. # subsequent iterations do not repeat the error-retry cycle.
if result.finish_reason != "error": if result.finish_reason != "error":
self._strip_image_content_inplace(original_messages) self._strip_media_content_inplace(original_messages)
return result return result
return response return response

View File

@ -5,6 +5,8 @@ from __future__ import annotations
import json import json
from typing import Any from typing import Any
from nanobot.providers.base import LLMProvider
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items. """Convert Chat Completions messages to Responses API input items.
@ -58,8 +60,10 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
def convert_user_message(content: Any) -> dict[str, Any]: def convert_user_message(content: Any) -> dict[str, Any]:
"""Convert a user message's content to Responses API format. """Convert a user message's content to Responses API format.
Handles plain strings, ``text`` blocks -> ``input_text``, and Handles plain strings, ``text`` blocks -> ``input_text``,
``image_url`` blocks -> ``input_image``. ``image_url`` blocks -> ``input_image``, and ``input_audio`` blocks.
``video_url`` is downgraded to a text placeholder because Codex does
not support native video.
""" """
if isinstance(content, str): if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]} return {"role": "user", "content": [{"type": "input_text", "text": content}]}
@ -74,6 +78,18 @@ def convert_user_message(content: Any) -> dict[str, Any]:
url = (item.get("image_url") or {}).get("url") url = (item.get("image_url") or {}).get("url")
if url: if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
elif item.get("type") == "input_audio":
audio_info = item.get("input_audio") or {}
audio_data = audio_info.get("data")
if audio_data:
converted.append({
"type": "input_audio",
"input_audio": {"data": audio_data, "format": audio_info.get("format", "wav")},
})
elif item.get("type") == "video_url":
# Codex doesn't support native video → text placeholder
placeholder = LLMProvider._media_placeholder("video_url", item)
converted.append({"type": "input_text", "text": placeholder["text"]})
if converted: if converted:
return {"role": "user", "content": converted} return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]} return {"role": "user", "content": [{"type": "input_text", "text": ""}]}

View File

@ -171,6 +171,79 @@ def detect_image_mime(data: bytes) -> str | None:
return None return None
# Audio formats supported by OpenAI input_audio block
_AUDIO_MIME_COMPAT = {"audio/wav", "audio/mpeg", "audio/mp3", "audio/aac",
"audio/ogg", "audio/flac", "audio/x-m4a", "audio/mp4"}
# Map MIME types to the format token expected by OpenAI-compatible input_audio APIs.
_AUDIO_FORMAT_MAP: dict[str, str] = {
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/ogg": "ogg",
"audio/flac": "flac",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
}
def detect_audio_mime(data: bytes, filename: str = "") -> str | None:
"""Detect audio MIME type from magic bytes; fallback to filename guess."""
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
return "audio/wav"
if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2", b"\xff\xfa"):
return "audio/mpeg"
if data[:4] == b"fLaC":
return "audio/flac"
if data[:4] == b"OggS":
return "audio/ogg"
if len(data) > 8 and data[4:8] == b"ftyp":
# Only claim audio for M4A-specific brands; avoid matching MP4 video.
brand = data[8:12]
if brand in (b"M4A ", b"M4AB", b"M4AC"):
return "audio/x-m4a"
if filename:
import mimetypes as _mt
guessed = _mt.guess_type(filename)[0]
if guessed and guessed.startswith("audio/"):
return guessed
return None
def audio_mime_compat(mime: str | None) -> bool:
"""Check if the audio MIME is compatible with OpenAI input_audio block."""
if not mime:
return False
return mime in _AUDIO_MIME_COMPAT
def audio_format_for_api(mime: str) -> str:
"""Convert an audio MIME type to the format token expected by the API.
Falls back to the subtype portion of the MIME (e.g. "x-m4a" from
"audio/x-m4a") when no explicit mapping exists.
"""
if not mime:
return "wav"
return _AUDIO_FORMAT_MAP.get(mime, mime.split("/")[-1])
# Video formats commonly supported by LLM APIs (data URI inline)
_VIDEO_MIME_COMPAT = {
"video/mp4", "video/quicktime", "video/x-m4v",
"video/webm", "video/x-matroska",
}
def video_mime_compat(mime: str | None) -> bool:
"""Check if the video MIME is in the commonly-supported set."""
if not mime:
return False
return mime in _VIDEO_MIME_COMPAT
def build_image_content_blocks( def build_image_content_blocks(
raw: bytes, mime: str, path: str, label: str raw: bytes, mime: str, path: str, label: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:

View File

@ -0,0 +1,178 @@
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.config.schema import InputLimitsConfig
from nanobot.utils.helpers import detect_audio_mime, video_mime_compat
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x04\x00\x01"
b"\x0b\x0e-\xb4"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
WAV_BYTES = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
def _builder(tmp_path: Path, input_limits: InputLimitsConfig | None = None) -> ContextBuilder:
return ContextBuilder(tmp_path, input_limits=input_limits)
class TestAudioDetection:
def test_detect_wav_from_magic_bytes(self) -> None:
assert detect_audio_mime(WAV_BYTES) == "audio/wav"
def test_detect_mp3_from_magic_bytes(self) -> None:
mp3 = b"\xff\xfb\x90\x00"
assert detect_audio_mime(mp3) == "audio/mpeg"
def test_detect_fallback_to_filename(self) -> None:
assert detect_audio_mime(b"unknown", filename="song.mp3") == "audio/mpeg"
def test_returns_none_for_non_audio(self) -> None:
assert detect_audio_mime(PNG_BYTES) is None
class TestVideoMimeCompat:
def test_mp4_is_compatible(self) -> None:
assert video_mime_compat("video/mp4") is True
def test_unknown_is_not_compatible(self) -> None:
assert video_mime_compat("video/avi") is False
def test_none_is_not_compatible(self) -> None:
assert video_mime_compat(None) is False
class TestBuildUserContentMultimodal:
def test_audio_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=True)
assert isinstance(content, list)
audio_blocks = [b for b in content if b.get("type") == "input_audio"]
assert len(audio_blocks) == 1
assert audio_blocks[0]["input_audio"]["format"] == "wav"
def test_audio_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=False)
assert isinstance(content, list)
assert any("[audio:" in b.get("text", "") for b in content)
def test_video_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=True)
assert isinstance(content, list)
video_blocks = [b for b in content if b.get("type") == "video_url"]
assert len(video_blocks) == 1
assert video_blocks[0]["video_url"]["url"].startswith("data:video/mp4;base64,")
def test_video_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=False)
assert isinstance(content, list)
assert any("[video:" in b.get("text", "") for b in content)
def test_vision_fallback_downgrades_image(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "pic.png"
path.write_bytes(PNG_BYTES)
content = builder._build_user_content("look", [str(path)], supports_vision=False)
assert isinstance(content, list)
assert any("[image:" in b.get("text", "") for b in content)
assert not any(b.get("type") == "image_url" for b in content)
def test_image_limit_count(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
paths = []
for i in range(5):
path = tmp_path / f"img{i}.png"
path.write_bytes(PNG_BYTES)
paths.append(str(path))
content = builder._build_user_content("describe", paths)
assert isinstance(content, list)
image_count = sum(1 for b in content if b.get("type") == "image_url")
assert image_count == 3 # default max_input_images
assert any("only the first 3 images" in b.get("text", "") for b in content)
def test_image_limit_bytes(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
big = tmp_path / "big.png"
big.write_bytes(PNG_BYTES + b"x" * builder.input_limits.max_input_image_bytes)
content = builder._build_user_content("analyze", [str(big)])
assert isinstance(content, str)
assert "file too large" in content
def test_audio_limit_count(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audios=1)
builder = _builder(tmp_path, input_limits=limits)
for i in range(2):
path = tmp_path / f"snd{i}.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content(
"compare", [str(tmp_path / "snd0.wav"), str(tmp_path / "snd1.wav")], supports_audio=True
)
assert isinstance(content, list)
audio_count = sum(1 for b in content if b.get("type") == "input_audio")
assert audio_count == 1
assert any("only 1 audio" in b.get("text", "") for b in content)
def test_audio_limit_bytes(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audio_bytes=32)
builder = _builder(tmp_path, input_limits=limits)
path = tmp_path / "big.wav"
path.write_bytes(WAV_BYTES + b"x" * 64)
content = builder._build_user_content("analyze", [str(path)], supports_audio=True)
assert isinstance(content, str)
assert "file too large" in content
def test_mixed_media_types(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
img = tmp_path / "pic.png"
img.write_bytes(PNG_BYTES)
snd = tmp_path / "voice.wav"
snd.write_bytes(WAV_BYTES)
vid = tmp_path / "clip.mp4"
vid.write_bytes(b"\x00" * 64)
content = builder._build_user_content(
"analyze",
[str(img), str(snd), str(vid)],
supports_vision=True,
supports_audio=True,
supports_video=True,
)
assert isinstance(content, list)
assert any(b.get("type") == "image_url" for b in content)
assert any(b.get("type") == "input_audio" for b in content)
assert any(b.get("type") == "video_url" for b in content)

View File

@ -27,10 +27,11 @@ def test_extract_post_content_supports_post_wrapper_shape() -> None:
} }
} }
text, image_keys = _extract_post_content(payload) text, image_keys, media_items = _extract_post_content(payload)
assert text == "日报 完成" assert text == "日报 完成"
assert image_keys == ["img_1"] assert image_keys == ["img_1"]
assert media_items == []
def test_extract_post_content_keeps_direct_shape_behavior() -> None: def test_extract_post_content_keeps_direct_shape_behavior() -> None:
@ -45,10 +46,29 @@ def test_extract_post_content_keeps_direct_shape_behavior() -> None:
], ],
} }
text, image_keys = _extract_post_content(payload) text, image_keys, media_items = _extract_post_content(payload)
assert text == "Daily report" assert text == "Daily report"
assert image_keys == ["img_a", "img_b"] assert image_keys == ["img_a", "img_b"]
assert media_items == []
def test_extract_post_content_extracts_media_tags() -> None:
payload = {
"title": "Video",
"content": [
[
{"tag": "text", "text": "see this"},
{"tag": "media", "file_key": "vid_1"},
]
],
}
text, image_keys, media_items = _extract_post_content(payload)
assert text == "Video see this"
assert image_keys == []
assert media_items == [{"tag": "media", "file_key": "vid_1"}]
def test_register_optional_event_keeps_builder_when_method_missing() -> None: def test_register_optional_event_keeps_builder_when_method_missing() -> None:

View File

@ -242,7 +242,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_image_fallback_without_meta_uses_default_placeholder() -> None: async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
"""When _meta is absent, fallback placeholder is '[image omitted]'.""" """When _meta is absent, fallback placeholder is '[image]'."""
provider = ScriptedProvider([ provider = ScriptedProvider([
LLMResponse(content="error", finish_reason="error"), LLMResponse(content="error", finish_reason="error"),
LLMResponse(content="ok"), LLMResponse(content="ok"),
@ -256,7 +256,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
for msg in msgs_on_retry: for msg in msgs_on_retry:
content = msg.get("content") content = msg.get("content")
if isinstance(content, list): if isinstance(content, list):
assert any("[image omitted]" in (b.get("text") or "") for b in content) assert any("[image]" in (b.get("text") or "") for b in content)
@pytest.mark.asyncio @pytest.mark.asyncio