diff --git a/src/document_processor.py b/src/document_processor.py index e96ec999c..8025e22e0 100644 --- a/src/document_processor.py +++ b/src/document_processor.py @@ -440,7 +440,10 @@ def build_user_content( try: with open(path, "rb") as image_file: encoded_string = base64.b64encode(image_file.read()).decode("utf-8") - image_format = ext[1:] + # Extensionless uploads (e.g. a pasted screenshot) have no ext, + # so fall back to the resolved MIME subtype rather than emitting + # an invalid "data:image/;base64," with an empty subtype. + image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png") content.append({ "type": "image_url", "image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"}, @@ -456,7 +459,7 @@ def build_user_content( try: with open(path, "rb") as audio_file: encoded_string = base64.b64encode(audio_file.read()).decode("utf-8") - audio_format = ext[1:] + audio_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("audio/") else "mpeg") content.append({ "type": "audio", "audio": {"url": f"data:audio/{audio_format};base64,{encoded_string}"}, diff --git a/tests/test_document_processor_empty_media_subtype.py b/tests/test_document_processor_empty_media_subtype.py new file mode 100644 index 000000000..63737c36c --- /dev/null +++ b/tests/test_document_processor_empty_media_subtype.py @@ -0,0 +1,74 @@ +"""Regression: extensionless image/audio uploads must get a valid MIME subtype. + +The data-URL subtype was derived only from the stored file's extension +(`image_format = ext[1:]`). A pasted screenshot or any file whose stored id +carries no extension yields `ext == ""`, so the emitted URL was +`data:image/;base64,...` — an empty MIME subtype (invalid per RFC 2046) that +vision/audio endpoints reject, silently dropping the attachment. When the +extension is missing, fall back to the resolved MIME subtype. Extensions that +are present are unchanged. +""" + + +class _Handler: + def __init__(self, uploads, image=False, audio=False): + self.uploads = uploads + self._image = image + self._audio = audio + + def resolve_upload(self, fid, owner=None): + return self.uploads.get(fid) + + def _inside_upload_dir(self, path): + return True + + def is_image_file(self, name, mime): + return self._image and (mime or "").startswith("image/") + + def is_audio_file(self, name, mime): + return self._audio and (mime or "").startswith("audio/") + + def is_document_file(self, name, mime): + return False + + +def _blocks(content, block_type): + return [b for b in content if isinstance(b, dict) and b.get("type") == block_type] + + +def test_extensionless_image_uses_mime_subtype(tmp_path): + import src.document_processor as dp + + p = tmp_path / ("a" * 32) # bare id, no extension + p.write_bytes(b"\x89PNG\r\n\x1a\nfake") + uploads = {"img": {"path": str(p), "name": "screenshot", "mime": "image/png"}} + + content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t") + imgs = _blocks(content, "image_url") + assert imgs, content + assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_extensionless_audio_uses_mime_subtype(tmp_path): + import src.document_processor as dp + + p = tmp_path / ("b" * 32) + p.write_bytes(b"fakeaudio") + uploads = {"aud": {"path": str(p), "name": "recording", "mime": "audio/mpeg"}} + + content = dp.build_user_content("listen", ["aud"], str(tmp_path), _Handler(uploads, audio=True), owner="t") + auds = _blocks(content, "audio") + assert auds, content + assert auds[0]["audio"]["url"].startswith("data:audio/mpeg;base64,") + + +def test_extension_present_is_unchanged(tmp_path): + import src.document_processor as dp + + p = tmp_path / "pic.png" + p.write_bytes(b"\x89PNG\r\n\x1a\n") + uploads = {"img": {"path": str(p), "name": "pic.png", "mime": "image/png"}} + + content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t") + imgs = _blocks(content, "image_url") + assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")