diff --git a/application/llm/openai.py b/application/llm/openai.py index 1d1115bd..0a0a27b2 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -266,7 +266,21 @@ class OpenAILLM(BaseLLM): filename = file_obj.get("filename") or "upload.pdf" file_data = file_obj.get("file_data") if file_data: - content_hash = hashlib.sha256(file_data.encode()).hexdigest() + payload = file_data + if payload.startswith("data:"): + _, _, payload = payload.partition(",") + # MIME-wrapped encoders (``base64.encodebytes``, some + # JSON pretty-printers) insert whitespace/newlines that + # ``validate=True`` rejects — strip so recoverable data + # doesn't get thrown into the text-note degrade path. + payload = "".join(payload.split()) + else: + payload = None + if payload: + # Hash the canonical payload, not the raw string, so data-URI, + # bare-base64, and MIME-wrapped encodings of the same bytes + # share one cache entry. + content_hash = hashlib.sha256(payload.encode()).hexdigest() cached = self._inline_file_ids.get(content_hash) if cached: return {"type": "file", "file": {"file_id": cached}} @@ -275,19 +289,6 @@ class OpenAILLM(BaseLLM): self._inline_file_ids[content_hash] = cached return {"type": "file", "file": {"file_id": cached}} try: - payload = file_data - if payload.startswith("data:"): - _, _, payload = payload.partition(",") - # MIME-wrapped encoders (``base64.encodebytes``, some - # JSON pretty-printers) insert whitespace/newlines that - # ``validate=True`` rejects — strip so recoverable data - # doesn't get thrown into the text-note degrade path. - payload = "".join(payload.split()) - if not payload: - # A data URI missing the comma (or one with an empty - # payload) would otherwise decode to zero bytes and - # upload an empty artifact; degrade deliberately. - raise ValueError("empty file_data payload") raw = base64.b64decode(payload, validate=True) file_id = self.client.files.create( file=(filename, io.BytesIO(raw)), @@ -303,6 +304,15 @@ class OpenAILLM(BaseLLM): filename, e, ) + elif file_data: + # A data URI missing the comma (or one with an empty payload) + # would otherwise decode to zero bytes and upload an empty + # artifact; degrade deliberately. + logging.warning( + "File content part '%s' has an empty file_data payload; " + "degrading to a text note", + filename, + ) else: logging.warning( "File content part '%s' has neither file_id nor file_data; " diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py index b03fd7f2..024ebe04 100644 --- a/tests/llm/test_openai.py +++ b/tests/llm/test_openai.py @@ -1618,6 +1618,27 @@ class TestInlineFilePartResolution: assert len(files.calls) == 1 assert out2[0]["content"][1]["file"] == {"file_id": "file-1"} + def test_equivalent_encodings_share_one_cache_entry(self, llm): + """Data-URI, bare-base64, and MIME-wrapped forms of the same bytes + must hash to one dedup key — the key is derived from the canonical + payload, not the raw ``file_data`` string.""" + files = _CountingFiles() + llm.client.files = files + variants = [ + f"data:application/pdf;base64,{_TINY_PDF_B64}", + _TINY_PDF_B64, + base64.encodebytes(_TINY_PDF_BYTES).decode(), + ] + for file_data in variants: + out = llm._clean_messages_openai( + self._msg({"filename": "a.pdf", "file_data": file_data}) + ) + assert out[0]["content"][1] == { + "type": "file", + "file": {"file_id": "file-1"}, + } + assert len(files.calls) == 1 + def test_upload_failure_degrades_to_text_note(self, llm): llm.client.files = _CountingFiles(fail=True) out = llm._clean_messages_openai(