From 95b8736e93bab46fef59b45e225eed17a97db4b2 Mon Sep 17 00:00:00 2001 From: fishzjp <105406371+fishzjp@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:07:43 +0800 Subject: [PATCH] fix(provider): tolerate trimmed image parts in litellm message conversion (#2475) SessionManager clears image_base64 on past turns to save memory, and exclude_none serialization drops the hollowed field entirely, so a replayed history part can arrive as {'type': 'image_base64'} with no payload. The converter accessed the missing key unconditionally and raised KeyError on every turn after an image was sent. Prefer the base64 payload when present, fall back to an image_url that survived on the same element, and drop hollow parts otherwise (same strategy as the existing file-part handling). Fixes #2469. --- .../modelmgr/requesters/litellmchat.py | 21 ++++++++-- .../provider/test_litellm_convert_messages.py | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index d23a4fc51..e33f7fd03 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester): converted_parts = [] for part in content: if isinstance(part, dict) and part.get('type') == 'image_base64': - part['image_url'] = {'url': part['image_base64']} - part['type'] = 'image_url' - del part['image_base64'] + # History trimming (SessionManager) clears image_base64 + # on past turns and exclude_none serialization drops + # the key entirely, so the replayed part may carry no + # payload. Prefer the base64 payload; fall back to an + # image_url that survived on the same element; drop + # hollow parts instead of raising KeyError (#2469). + image_b64 = part.get('image_base64') + fallback_url = None + if not image_b64: + raw_image_url = part.get('image_url') + if isinstance(raw_image_url, dict): + fallback_url = raw_image_url.get('url') + if image_b64 or fallback_url: + part['image_url'] = {'url': image_b64 or fallback_url} + part['type'] = 'image_url' + part.pop('image_base64', None) + else: + continue # OpenAI-compatible chat models reject non-image file parts # (audio/document base64 or url). These originate from Voice / # File attachments — including ones replayed from conversation diff --git a/tests/unit_tests/provider/test_litellm_convert_messages.py b/tests/unit_tests/provider/test_litellm_convert_messages.py index 87ad2e027..989db9bae 100644 --- a/tests/unit_tests/provider/test_litellm_convert_messages.py +++ b/tests/unit_tests/provider/test_litellm_convert_messages.py @@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched(): msg = provider_message.Message(role='user', content='just text') out = req._convert_messages([msg]) assert out[0]['content'] == 'just text' + + +def test_convert_messages_replayed_image_without_base64_does_not_crash(): + """Replayed image parts hollowed out by history trimming must not raise KeyError (#2469). + + SessionManager clears image_base64 on past turns, and URL-less platform + images never had a URL, so the replayed part serializes as + {'type': 'image_base64'} with no payload keys. The hollow part should be + dropped while the sibling text part survives. + """ + req = _make_requester() + image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA') + # Simulate SessionManager.trim_conversation_messages clearing binary payloads. + image.image_base64 = None + msg = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('describe the photo'), + image, + ], + ) + out = req._convert_messages([msg]) + assert [p.get('type') for p in out[0]['content']] == ['text'] + + +def test_convert_messages_replayed_image_with_url_falls_back_to_url(): + """When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL.""" + req = _make_requester() + image = provider_message.ContentElement( + type='image_base64', + image_base64=None, + image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'), + ) + msg = provider_message.Message(role='user', content=[image]) + out = req._convert_messages([msg]) + parts = out[0]['content'] + assert [p.get('type') for p in parts] == ['image_url'] + assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'} + assert 'image_base64' not in parts[0]