Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

368 lines
13 KiB
Python

"""
Multimodal Message Utilities
=============================
Converts plain-text messages + image attachments into the multimodal
message format expected by vision-capable LLMs.
Supports:
- OpenAI-compatible API (content array with image_url blocks)
- Anthropic API (content array with image source blocks)
"""
from __future__ import annotations
import base64 as _b64
from dataclasses import dataclass
import logging
from typing import Any
from urllib.parse import unquote, urlparse
from .capabilities import supports_vision, supports_vision_url
logger = logging.getLogger(__name__)
MIME_FALLBACK = "image/png"
_LOCAL_ATTACHMENT_PREFIX = "/api/attachments/"
@dataclass
class MultimodalResult:
"""Result of Stage-1 multimodal message preparation.
Images are injected optimistically for every provider, so there is no
"stripped because unsupported" outcome here — that decision is deferred
to the Stage-2 fallback at each call site's retry seam (see
:func:`should_degrade_to_text`).
"""
messages: list[dict[str, Any]]
# Number of url-only images we had to drop because the provider requires
# base64 and we couldn't resolve the URL locally (external URL or missing
# file). The caller can surface this to the user.
url_images_dropped: int = 0
def _guess_mime_type(filename: str, fallback: str = MIME_FALLBACK) -> str:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
}.get(ext, fallback)
def _build_openai_image_part(
*,
base64_data: str,
mime_type: str,
url: str = "",
) -> dict[str, Any]:
if url:
image_url = url
else:
image_url = f"data:{mime_type};base64,{base64_data}"
return {"type": "image_url", "image_url": {"url": image_url}}
def _build_anthropic_image_part(
*,
base64_data: str,
mime_type: str,
) -> dict[str, Any]:
return {
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
},
}
def _resolve_local_attachment_url(url: str) -> tuple[str, str] | None:
"""Resolve a ``/api/attachments/<sid>/<aid>/<name>`` URL to (base64, mime).
External URLs (http/https) are not fetched here — that would be sync
network IO inside an async-friendly path and a security footgun. Returns
None for anything we cannot resolve from the local AttachmentStore.
"""
if not url:
return None
parsed = urlparse(url)
path = parsed.path or url
if not path.startswith(_LOCAL_ATTACHMENT_PREFIX):
return None
parts = path[len(_LOCAL_ATTACHMENT_PREFIX) :].split("/")
if len(parts) != 3:
return None
sid, aid, name = (unquote(p) for p in parts)
try:
# Local import to avoid an import-time cycle: storage already imports
# capabilities indirectly via path service.
from deeptutor.services.storage import get_attachment_store
store = get_attachment_store()
resolve = getattr(store, "resolve_path", None)
if resolve is None:
return None
target = resolve(session_id=sid, attachment_id=aid, filename=name)
if target is None:
return None
data = target.read_bytes()
except Exception as exc:
logger.warning("failed to resolve local attachment %s: %s", url, exc)
return None
return _b64.b64encode(data).decode("ascii"), _guess_mime_type(name)
def prepare_multimodal_messages(
messages: list[dict[str, Any]],
attachments: list[Any] | None,
binding: str = "openai",
model: str | None = None,
) -> MultimodalResult:
"""
Inject image attachments into the last user message (Stage 1).
Images are injected **optimistically for every provider/model** — this
function does not consult ``supports_vision``. A model that natively
understands images therefore always receives them, even one DeepTutor has
no capability entry for (the original Doubao/VolcEngine bug). When a model
genuinely cannot handle images the request fails and the Stage-2 fallback
(:func:`should_degrade_to_text` + :func:`strip_image_parts`, applied at
each call site's retry seam) strips the images and retries as text-only.
The last user message ``content`` is converted from a plain string into a
content-parts array holding the original text plus the image(s). The only
images dropped *here* are url-only attachments the provider can't accept in
URL form (Anthropic, or ``vision_url_supported=False``) and that can't be
resolved to local bytes — counted in ``url_images_dropped``.
Args:
messages: The OpenAI-style messages list (may be mutated).
attachments: ``Attachment`` objects from ``UnifiedContext``.
binding: Provider binding (``"openai"``, ``"anthropic"``, …).
model: Model name (used only to pick the URL-vs-base64 image format).
Returns:
A ``MultimodalResult`` with the (potentially modified) messages.
"""
if not attachments:
return MultimodalResult(messages=messages)
image_attachments = [a for a in attachments if getattr(a, "type", "") == "image"]
if not image_attachments:
return MultimodalResult(messages=messages)
last_user_idx = _find_last_user_message(messages)
if last_user_idx is None:
return MultimodalResult(messages=messages)
is_anthropic = (binding or "").lower() in ("anthropic", "claude")
# Anthropic adapter only emits base64 source blocks, and providers like
# Moonshot / VolcEngine reject URL form outright. In both cases url-only
# attachments must be resolved to bytes before injection.
require_base64 = is_anthropic or not supports_vision_url(binding, model)
dropped = _inject_images(
messages,
last_user_idx,
image_attachments,
anthropic=is_anthropic,
require_base64=require_base64,
)
return MultimodalResult(messages=messages, url_images_dropped=dropped)
def _find_last_user_message(messages: list[dict[str, Any]]) -> int | None:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
return i
return None
def _inject_images(
messages: list[dict[str, Any]],
user_idx: int,
image_attachments: list[Any],
*,
anthropic: bool = False,
require_base64: bool = False,
) -> int:
"""Inject image parts into the user message at *user_idx*.
Returns the count of url-only attachments we had to drop because the
provider needs base64 and the URL could not be resolved locally.
"""
msg = messages[user_idx]
original_content = msg.get("content", "")
if isinstance(original_content, str):
content_parts: list[dict[str, Any]] = [{"type": "text", "text": original_content}]
elif isinstance(original_content, list):
content_parts = list(original_content)
else:
content_parts = [{"type": "text", "text": str(original_content)}]
dropped = 0
for att in image_attachments:
mime = getattr(att, "mime_type", "") or _guess_mime_type(
getattr(att, "filename", "image.png")
)
b64 = getattr(att, "base64", "") or ""
url = getattr(att, "url", "") or ""
if not b64 and not url:
continue
# Local AttachmentStore URLs ("/api/attachments/...") are server-
# relative paths and are never valid to send to an external LLM
# provider — even providers that accept image URLs would receive a
# path they can't fetch. Resolve them to base64 unconditionally so
# the inline-base64 branch below takes over.
is_local_attachment_url = url.startswith(_LOCAL_ATTACHMENT_PREFIX) if url else False
if not b64 and url and (require_base64 or is_local_attachment_url):
resolved = _resolve_local_attachment_url(url)
if resolved is not None:
b64, resolved_mime = resolved
mime = mime or resolved_mime
elif require_base64:
logger.warning(
"Dropping url-only image %r: provider requires base64 but"
" URL is not a resolvable local attachment-store path",
url,
)
dropped += 1
continue
elif is_local_attachment_url:
logger.warning(
"Dropping local attachment URL %r that could not be"
" resolved from the AttachmentStore",
url,
)
dropped += 1
continue
if anthropic:
if not b64:
logger.warning("Anthropic image part requires base64; dropping %r", url)
dropped += 1
continue
content_parts.append(_build_anthropic_image_part(base64_data=b64, mime_type=mime))
else:
if b64:
# Always prefer inline base64 when available — providers that
# reject URL form (Moonshot) accept this; providers that
# accept URLs accept this too.
content_parts.append(_build_openai_image_part(base64_data=b64, mime_type=mime))
else:
content_parts.append(
_build_openai_image_part(base64_data="", mime_type=mime, url=url)
)
messages[user_idx] = {**msg, "content": content_parts}
return dropped
_IMAGE_BLOCK_TYPES = frozenset({"image_url", "image"})
def _block_image_placeholder(block: dict[str, Any]) -> str:
"""Human-readable text placeholder for an image block being stripped."""
meta = block.get("_meta") or {}
label = ""
if isinstance(meta, dict):
label = str(meta.get("path") or meta.get("filename") or "").strip()
if not label and block.get("type") == "image_url":
image_url = block.get("image_url") or {}
if isinstance(image_url, dict):
url = str(image_url.get("url") or "").strip()
if url and not url.startswith("data:"):
label = url
return f"[image: {label}]" if label else "[image omitted]"
def has_image_parts(messages: list[dict[str, Any]]) -> bool:
"""Return True when any message content contains image blocks."""
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
continue
for item in content:
if isinstance(item, dict) and item.get("type") in _IMAGE_BLOCK_TYPES:
return True
return False
def strip_image_parts(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return a **new** message list with image blocks replaced by text
placeholders. Use when the caller must preserve the original (e.g. to
attempt a text-only retry while keeping the image payload for a possible
second provider)."""
stripped: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
stripped.append(dict(msg))
continue
new_content: list[dict[str, Any]] = [
{"type": "text", "text": _block_image_placeholder(item)}
if isinstance(item, dict) and item.get("type") in _IMAGE_BLOCK_TYPES
else item
for item in content
]
stripped.append({**msg, "content": new_content})
return stripped
def strip_image_parts_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image blocks with text placeholders **in place**; return True
if any were replaced.
Used by call sites that share one message list across retries / loop
iterations (the chat agentic loop) so the degrade persists and images are
not re-sent — and re-rejected — on every subsequent call."""
found = False
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
continue
for idx, block in enumerate(content):
if isinstance(block, dict) and block.get("type") in _IMAGE_BLOCK_TYPES:
content[idx] = {"type": "text", "text": _block_image_placeholder(block)}
found = True
return found
def should_degrade_to_text(
binding: str | None,
model: str | None,
messages: list[dict[str, Any]],
) -> bool:
"""Stage-2 fallback decision.
After a request that carried image content fails, return True when we
should strip the images and retry as text-only — i.e. the payload
actually had image parts **and** the model is *not* in the known-vision
allowlist (``supports_vision`` is False). For allowlisted (known
vision-capable) models we keep the images so a genuine error surfaces
instead of silently returning a misleading text-only answer.
"""
if not has_image_parts(messages):
return False
return not supports_vision(binding or "openai", model)
__all__ = [
"MultimodalResult",
"has_image_parts",
"prepare_multimodal_messages",
"should_degrade_to_text",
"strip_image_parts",
"strip_image_parts_inplace",
]