fix(chat): give extensionless image/audio uploads a valid MIME subtype (#5205)

build_user_content derived the data-URL subtype from the file extension
only (image_format = ext[1:]). An extensionless upload (e.g. a pasted
screenshot) has ext == "", producing "data:image/;base64,..." with an
empty subtype (invalid per RFC 2046) that vision/audio endpoints reject,
silently dropping the attachment. Fall back to the resolved MIME subtype
when the extension is missing; present extensions are unchanged.
This commit is contained in:
Miraç Duran
2026-07-08 22:04:15 +03:00
committed by GitHub
parent 2d8177035b
commit 3064819e3d
2 changed files with 79 additions and 2 deletions
+5 -2
View File
@@ -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}"},
@@ -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,")