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]