From d29a7aaf19742a574682a22140c4bd7927534681 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Jul 2026 14:28:52 +0100 Subject: [PATCH 1/2] fix: bug on openai compat file upload --- application/llm/openai.py | 140 +++++++++++++++- tests/conftest.py | 23 +++ tests/llm/test_openai.py | 332 +++++++++++++++++++++++++++++++++++++- 3 files changed, 493 insertions(+), 2 deletions(-) diff --git a/application/llm/openai.py b/application/llm/openai.py index 8f62f1a5..1d1115bd 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -1,5 +1,6 @@ import base64 import hashlib +import io import json import logging @@ -172,6 +173,12 @@ class OpenAILLM(BaseLLM): # fallback restream of the already-delivered answer. self._stream_reached_finish = False self._imported_response_id = None + # Files-API ids for inline ``file_data`` content parts already + # uploaded, keyed by content hash. First-line cache for the + # in-request tool loop; the Redis-backed cross-request cache + # (see ``_inline_file_id_cache_*``) covers /v1 clients that + # resend the same ``file_data`` on every turn. + self._inline_file_ids = {} def responses_chain_key(self) -> str: """Return a credential- and endpoint-scoped Responses chain key. @@ -236,6 +243,137 @@ class OpenAILLM(BaseLLM): self._imported_response_id = None self._last_finish_reason = None + def _resolve_file_part(self, item): + """Resolve a ``file`` content part into a Files-API reference. + + Clients (the /v1 passthrough in particular) may send OpenAI-style + file parts carrying an inline ``file_data`` data-URI. Azure's + Responses API rejects inline data with ``unsupported_file`` even for + valid PDFs, and string-content-only chat deployments 4xx on any file + part — so upload the bytes once and swap in the ``file_id`` the + deployments do accept. A part with neither ``file_id`` nor decodable + ``file_data``, or whose upload fails (e.g. the endpoint has no Files + API), degrades to a text note instead of a certain provider 4xx. + """ + file_obj = item.get("file") or {} + if file_obj.get("file_id"): + # Normalize: a client that sends both ``file_id`` and + # ``file_data`` would otherwise leak the inline payload to + # ``_responses_content_parts``, which copies every truthy key + # into ``input_file`` — and Azure Responses then rejects on + # the ``file_data`` regardless of the ``file_id``. + return {"type": "file", "file": {"file_id": file_obj["file_id"]}} + 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() + cached = self._inline_file_ids.get(content_hash) + if cached: + return {"type": "file", "file": {"file_id": cached}} + cached = self._inline_file_id_cache_get(content_hash) + if cached: + 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)), + purpose="assistants", + ).id + self._inline_file_ids[content_hash] = file_id + self._inline_file_id_cache_set(content_hash, file_id) + return {"type": "file", "file": {"file_id": file_id}} + except Exception as e: + logging.warning( + "Could not resolve inline file_data part '%s' to a " + "file_id (%s); degrading to a text note", + filename, + e, + ) + else: + logging.warning( + "File content part '%s' has neither file_id nor file_data; " + "degrading to a text note", + filename, + ) + return { + "type": "text", + "text": f"[File '{filename}' could not be processed]", + } + + def _inline_file_id_cache_key(self, content_hash: str) -> str: + """Redis key for the inline-file-data → file_id cache. + + Scoped by ``(provider_name, base_url, api_key)``: a Files-API + ``file_id`` is only valid for the endpoint + credential it was + uploaded to, so a shared key across providers would return + ids the current call can't use. + """ + creds = "\0".join( + ( + self.provider_name or "", + self._effective_base_url or "", + self.api_key or "", + ) + ) + creds_hash = hashlib.sha256(creds.encode("utf-8")).hexdigest()[:16] + return f"openai_inline_file:{creds_hash}:{content_hash}" + + def _inline_file_id_cache_get(self, content_hash: str): + """Look up a previously-uploaded file_id for this content. + + Returns None on cache miss or on any Redis error — the caller + then uploads normally. Never raises. + """ + try: + from application.cache import get_redis_instance + + r = get_redis_instance() + if r is None: + return None + value = r.get(self._inline_file_id_cache_key(content_hash)) + if value is None: + return None + return value.decode() if isinstance(value, (bytes, bytearray)) else value + except Exception as e: + logging.debug("inline_file_id cache read failed: %s", e) + return None + + def _inline_file_id_cache_set(self, content_hash: str, file_id: str) -> None: + """Persist a fresh (content_hash → file_id) mapping. + + 24 h TTL: well inside Azure's 30-day retention for + ``purpose="assistants"``, long enough to cover multi-turn + conversations that resend the same ``file_data`` every turn. + Silent on failure. + """ + try: + from application.cache import get_redis_instance + + r = get_redis_instance() + if r is None: + return + r.setex( + self._inline_file_id_cache_key(content_hash), + 86400, + file_id, + ) + except Exception as e: + logging.debug("inline_file_id cache write failed: %s", e) + def _clean_messages_openai(self, messages): cleaned_messages = [] for message in messages: @@ -346,7 +484,7 @@ class OpenAILLM(BaseLLM): if "type" in item and item["type"] == "text" and "text" in item: content_parts.append(item) elif "type" in item and item["type"] == "file" and "file" in item: - content_parts.append(item) + content_parts.append(self._resolve_file_part(item)) elif "type" in item and item["type"] == "image_url" and "image_url" in item: content_parts.append(item) elif "text" in item and "type" not in item: diff --git a/tests/conftest.py b/tests/conftest.py index 091f08b9..74bd3ff8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -138,6 +138,29 @@ def pg_conn(pg_engine): # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _no_real_redis(monkeypatch): + """Force the Redis-absent baseline CI has. + + CI runs without a Redis service, so ``get_redis_instance()`` / + ``get_pubsub_redis_instance()`` return None there. A dev machine with + a live localhost Redis diverges: code under test silently reads and + writes real keys, leaking state between tests and between runs (seen + with the ``openai_inline_file:*`` upload cache). Flipping the + creation-failed flags makes the real accessors return None everywhere + regardless of import style — consumers that did ``from + application.cache import get_redis_instance`` still hit these module + globals at call time. Tests that want Redis behavior keep injecting + fakes by patching the accessor at the consumer module, which bypasses + this guard. A test targeting the accessor's own construction path + must reset the two flags first. + """ + monkeypatch.setattr("application.cache._redis_instance", None) + monkeypatch.setattr("application.cache._redis_creation_failed", True) + monkeypatch.setattr("application.cache._pubsub_redis_instance", None) + monkeypatch.setattr("application.cache._pubsub_redis_creation_failed", True) + + @pytest.fixture def mock_llm(): llm = Mock() diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py index 486c5ebf..b03fd7f2 100644 --- a/tests/llm/test_openai.py +++ b/tests/llm/test_openai.py @@ -14,8 +14,9 @@ Extends coverage beyond test_openai_llm.py: - _get_base64_image / _upload_file_to_openai """ +import base64 import types -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -1540,3 +1541,332 @@ class TestUploadFileToOpenAIError: ) with pytest.raises(FileNotFoundError, match="File not found"): llm._upload_file_to_openai({"path": "/missing.pdf"}) + + +# _clean_messages_openai — inline file_data resolution (ledger #1 unsupported_file) + + +_TINY_PDF_BYTES = b"%PDF-1.4 tiny e2e stub" +_TINY_PDF_B64 = base64.b64encode(_TINY_PDF_BYTES).decode() + + +class _CountingFiles: + """Files-API stub that records uploads and can simulate an endpoint + without a Files API (the OpenAI-compatible fallback deployments).""" + + def __init__(self, fail=False): + self.calls = [] + self.fail = fail + + def create(self, file=None, purpose=None): + self.calls.append((file, purpose)) + if self.fail: + raise RuntimeError("files API unavailable") + return types.SimpleNamespace(id=f"file-{len(self.calls)}") + + +class TestInlineFilePartResolution: + def _msg(self, file_obj): + return [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is in the file?"}, + {"type": "file", "file": file_obj}, + ], + } + ] + + def test_file_data_uri_uploaded_and_swapped_for_file_id(self, llm): + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg( + { + "filename": "Scan-48.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}", + } + ) + ) + parts = out[0]["content"] + assert parts[0] == {"type": "text", "text": "what is in the file?"} + assert parts[1] == {"type": "file", "file": {"file_id": "file-1"}} + ((uploaded, purpose),) = files.calls + assert purpose == "assistants" + assert uploaded[0] == "Scan-48.pdf" + assert uploaded[1].read() == _TINY_PDF_BYTES + + def test_raw_base64_without_data_uri_prefix(self, llm): + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg({"filename": "a.pdf", "file_data": _TINY_PDF_B64}) + ) + assert out[0]["content"][1]["file"] == {"file_id": "file-1"} + + def test_same_file_data_uploaded_once(self, llm): + files = _CountingFiles() + llm.client.files = files + msg = self._msg( + { + "filename": "a.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}", + } + ) + llm._clean_messages_openai(msg) + out2 = llm._clean_messages_openai(msg) + assert len(files.calls) == 1 + assert out2[0]["content"][1]["file"] == {"file_id": "file-1"} + + def test_upload_failure_degrades_to_text_note(self, llm): + llm.client.files = _CountingFiles(fail=True) + out = llm._clean_messages_openai( + self._msg( + { + "filename": "Scan-48.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}", + } + ) + ) + parts = out[0]["content"] + assert parts[1]["type"] == "text" + assert "Scan-48.pdf" in parts[1]["text"] + + def test_invalid_base64_degrades_to_text_note(self, llm): + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg({"filename": "x.pdf", "file_data": "data:application/pdf;base64,%%%not-base64%%%"}) + ) + assert out[0]["content"][1]["type"] == "text" + assert files.calls == [] + + def test_existing_file_id_part_untouched(self, llm): + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai(self._msg({"file_id": "file-abc"})) + assert out[0]["content"][1] == { + "type": "file", + "file": {"file_id": "file-abc"}, + } + assert files.calls == [] + + def test_empty_file_part_degrades_to_text_note(self, llm): + # The classic ledger-#1 flavor: {"type": "file", "file": {"file_id": None}} + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai(self._msg({"file_id": None})) + parts = out[0]["content"] + assert parts[1]["type"] == "text" + assert files.calls == [] + + def test_responses_parts_conversion_gets_file_id(self, llm): + files = _CountingFiles() + llm.client.files = files + cleaned = llm._clean_messages_openai( + self._msg( + { + "filename": "a.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}", + } + ) + ) + parts = OpenAILLM._responses_content_parts("user", cleaned[0]["content"]) + assert {"type": "input_file", "file_id": "file-1"} in parts + + # -- Fix #1: file_id + file_data both present must drop file_data -- + + def test_file_id_and_file_data_normalized_to_file_id_only(self, llm): + """A client sending both keys would otherwise leak ``file_data`` + into ``_responses_content_parts`` (Azure Responses then 400s on + the inline payload regardless of the ``file_id``).""" + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg( + { + "file_id": "file-preexisting", + "filename": "Scan-48.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}", + } + ) + ) + part = out[0]["content"][1] + # Normalized: only ``file_id`` survives — no ``file_data``, no + # stray ``filename`` that _responses_content_parts would ship. + assert part == {"type": "file", "file": {"file_id": "file-preexisting"}} + # No spurious upload — the client already had a file_id. + assert files.calls == [] + # And the Responses translator no longer sees any inline data. + rparts = OpenAILLM._responses_content_parts("user", out[0]["content"]) + assert {"type": "input_file", "file_id": "file-preexisting"} in rparts + assert not any("file_data" in p for p in rparts) + + # -- Fix #3: whitespace/newline-wrapped base64 must decode -- + + def test_mime_wrapped_base64_decodes_cleanly(self, llm): + """``base64.encodebytes`` line-wraps every 76 chars; pretty-printed + JSON producers may inject ``\\n``. With ``validate=True`` those + would raise, throwing recoverable payloads into the degrade path.""" + files = _CountingFiles() + llm.client.files = files + wrapped_b64 = base64.encodebytes(_TINY_PDF_BYTES).decode() # ends with \n + assert "\n" in wrapped_b64 + out = llm._clean_messages_openai( + self._msg( + { + "filename": "wrapped.pdf", + "file_data": f"data:application/pdf;base64,{wrapped_b64}", + } + ) + ) + assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}} + ((uploaded, _),) = files.calls + # The decoded bytes must equal the ORIGINAL — no whitespace bleed. + assert uploaded[1].read() == _TINY_PDF_BYTES + + # -- Fix #4: no-comma data URI must degrade, not upload zero bytes -- + + def test_data_uri_missing_comma_degrades_without_upload(self, llm): + """``"data:application/pdf;base64".partition(",")`` -> empty payload; + the previous code decoded ``b""`` and shipped a zero-byte file.""" + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg( + { + "filename": "no-comma.pdf", + "file_data": "data:application/pdf;base64", + } + ) + ) + part = out[0]["content"][1] + assert part["type"] == "text" + assert "no-comma.pdf" in part["text"] + assert files.calls == [] # no zero-byte artifact left in provider bucket + + def test_data_uri_with_comma_but_empty_body_degrades(self, llm): + """Guard covers the near-neighbour: comma present, body empty.""" + files = _CountingFiles() + llm.client.files = files + out = llm._clean_messages_openai( + self._msg({"filename": "empty.pdf", "file_data": "data:application/pdf;base64,"}) + ) + assert out[0]["content"][1]["type"] == "text" + assert files.calls == [] + + # -- Fix #2: Redis cache — cross-request dedup for /v1 replays -- + + def _patch_redis(self, cache): + """Patch ``application.cache.get_redis_instance`` to return the + provided fake redis (a dict-backed stub) — one context per test. + + The helpers ``_inline_file_id_cache_*`` do ``from application.cache + import get_redis_instance`` INSIDE the function, so patching the + module attribute is enough — no import-time capture to worry about. + """ + return patch("application.cache.get_redis_instance", return_value=cache) + + class _FakeRedis: + def __init__(self): + self.store = {} + self.setex_calls = [] + self.get_calls = [] + + def get(self, key): + self.get_calls.append(key) + return self.store.get(key) + + def setex(self, key, ttl, value): + self.setex_calls.append((key, ttl, value)) + self.store[key] = value.encode() if isinstance(value, str) else value + + def test_redis_cache_hit_skips_upload_across_instances(self, llm): + """Two independent OpenAILLM instances (per-request creation is how + LLMCreator works) sharing the same credential must both see the + cached file_id after the first uploads it.""" + cache = self._FakeRedis() + files_1 = _CountingFiles() + llm.client.files = files_1 + msg = self._msg( + {"filename": "Scan-48.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"} + ) + with self._patch_redis(cache): + llm._clean_messages_openai(msg) + # First call: upload happened, cache populated with a real TTL. + assert len(files_1.calls) == 1 + assert len(cache.setex_calls) == 1 + key, ttl, val = cache.setex_calls[0] + assert key.startswith("openai_inline_file:") and ttl == 86400 and val == "file-1" + + # Second call, second instance (simulates the next /v1 turn's + # freshly-constructed LLM), same credentials → cache-hit path. + llm2 = OpenAILLM(api_key="sk-test", user_api_key=None) + llm2.client = types.SimpleNamespace(files=_CountingFiles()) + with self._patch_redis(cache): + out2 = llm2._clean_messages_openai(msg) + assert out2[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}} + # No new upload — the second instance's Files stub is untouched. + assert llm2.client.files.calls == [] + + def test_redis_cache_key_isolates_by_credential(self, llm): + """A file_id is only valid on the endpoint+credential it was + uploaded to; the key must therefore not collide across credentials + (else a foundry-uploaded id would be handed to a byom endpoint).""" + cache = self._FakeRedis() + llm._effective_base_url = "https://foundry.example/openai/v1" + llm.api_key = "sk-foundry" + llm.client.files = _CountingFiles() + msg = self._msg( + {"filename": "x.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"} + ) + with self._patch_redis(cache): + llm._clean_messages_openai(msg) + + llm2 = OpenAILLM(api_key="sk-byom", user_api_key=None) + llm2._effective_base_url = "https://byom.example/v1" + llm2.client = types.SimpleNamespace(files=_CountingFiles()) + with self._patch_redis(cache): + llm2._clean_messages_openai(msg) + # Second credential missed the cache → uploaded again. + assert len(llm2.client.files.calls) == 1 + # Two distinct cache keys landed in Redis, one per credential. + assert len(cache.setex_calls) == 2 + assert cache.setex_calls[0][0] != cache.setex_calls[1][0] + + def test_redis_unreachable_falls_back_to_upload(self, llm): + """If Redis is down, ``get_redis_instance`` returns None — the + code must upload normally and never raise.""" + llm.client.files = _CountingFiles() + msg = self._msg( + {"filename": "x.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"} + ) + with self._patch_redis(None): + out = llm._clean_messages_openai(msg) + assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}} + assert len(llm.client.files.calls) == 1 + + def test_redis_read_exception_swallowed(self, llm): + """A transient Redis error mid-request must not surface — degrade + to an upload silently.""" + + class BrokenRedis: + def get(self, key): + raise RuntimeError("connection reset") + + def setex(self, key, ttl, value): + raise RuntimeError("connection reset") + + llm.client.files = _CountingFiles() + msg = self._msg( + {"filename": "x.pdf", + "file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"} + ) + with self._patch_redis(BrokenRedis()): + out = llm._clean_messages_openai(msg) + # Upload happened because the read errored → treated as miss. + assert len(llm.client.files.calls) == 1 + assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}} From b4211944f1ad7efbbbb3d002363d109be63a7a78 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Jul 2026 15:16:27 +0100 Subject: [PATCH 2/2] fix: dedup nit --- application/llm/openai.py | 38 ++++++++++++++++++++++++-------------- tests/llm/test_openai.py | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) 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(