From ae3f3755210ef6fb5824a78683b5ce772cc3c55c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:16:49 +0000 Subject: [PATCH] fix: strip File objects from messages when multimodal is unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4498. When returns False, previously returned early without removing the key from messages. This left non-serializable File objects in the payload, causing TypeError during JSON serialization by httpx. Now the key is always stripped from every message, with a warning logged when files are dropped because the model lacks multimodal support or crewai-files is not installed. Co-Authored-By: João --- lib/crewai/src/crewai/llm.py | 38 +++-- lib/crewai/src/crewai/llms/base_llm.py | 21 ++- lib/crewai/tests/llms/test_multimodal.py | 171 ++++++++++++++++++++++- 3 files changed, 215 insertions(+), 15 deletions(-) diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index 20a0373cb..89af90970 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -1978,22 +1978,33 @@ class LLM(BaseLLM): For each message with a `files` field, formats the files into provider-specific content blocks and updates the message content. + Always strips the ``files`` key so that non-serializable objects + never leak into the API payload. + Args: messages: List of messages that may contain file attachments. Returns: Messages with files formatted into content blocks. """ - if not HAS_CREWAI_FILES or not self.supports_multimodal(): - return messages - - provider = getattr(self, "provider", None) or self.model + can_process = HAS_CREWAI_FILES and self.supports_multimodal() for msg in messages: files = msg.get("files") if not files: continue + if not can_process: + logging.warning( + "Files were attached to a message but the model does not " + "support multimodal input or crewai-files is not installed. " + "The files have been dropped from the request." + ) + msg.pop("files", None) + continue + + provider = getattr(self, "provider", None) or self.model + content_blocks = format_multimodal_content(files, provider) if not content_blocks: msg.pop("files", None) @@ -2020,22 +2031,33 @@ class LLM(BaseLLM): For each message with a `files` field, formats the files into provider-specific content blocks and updates the message content. + Always strips the ``files`` key so that non-serializable objects + never leak into the API payload. + Args: messages: List of messages that may contain file attachments. Returns: Messages with files formatted into content blocks. """ - if not HAS_CREWAI_FILES or not self.supports_multimodal(): - return messages - - provider = getattr(self, "provider", None) or self.model + can_process = HAS_CREWAI_FILES and self.supports_multimodal() for msg in messages: files = msg.get("files") if not files: continue + if not can_process: + logging.warning( + "Files were attached to a message but the model does not " + "support multimodal input or crewai-files is not installed. " + "The files have been dropped from the request." + ) + msg.pop("files", None) + continue + + provider = getattr(self, "provider", None) or self.model + content_blocks = await aformat_multimodal_content(files, provider) if not content_blocks: msg.pop("files", None) diff --git a/lib/crewai/src/crewai/llms/base_llm.py b/lib/crewai/src/crewai/llms/base_llm.py index dcb261fd7..dc1024725 100644 --- a/lib/crewai/src/crewai/llms/base_llm.py +++ b/lib/crewai/src/crewai/llms/base_llm.py @@ -595,26 +595,37 @@ class BaseLLM(ABC): For each message with a `files` field, formats the files into provider-specific content blocks and updates the message content. + Always strips the ``files`` key so that non-serializable objects + never leak into the API payload. + Args: messages: List of messages that may contain file attachments. Returns: Messages with files formatted into content blocks. """ - if not HAS_CREWAI_FILES or not self.supports_multimodal(): - return messages - - provider = getattr(self, "provider", None) or getattr(self, "model", "openai") - api = getattr(self, "api", None) + can_process = HAS_CREWAI_FILES and self.supports_multimodal() for msg in messages: files = msg.get("files") if not files: continue + if not can_process: + logging.warning( + "Files were attached to a message but the model does not " + "support multimodal input or crewai-files is not installed. " + "The files have been dropped from the request." + ) + msg.pop("files", None) + continue + existing_content = msg.get("content", "") text = existing_content if isinstance(existing_content, str) else None + provider = getattr(self, "provider", None) or getattr(self, "model", "openai") + api = getattr(self, "api", None) + content_blocks = format_multimodal_content( files, provider, api=api, prefer_upload=self.prefer_upload, text=text ) diff --git a/lib/crewai/tests/llms/test_multimodal.py b/lib/crewai/tests/llms/test_multimodal.py index cde9e13d3..dc228e903 100644 --- a/lib/crewai/tests/llms/test_multimodal.py +++ b/lib/crewai/tests/llms/test_multimodal.py @@ -1,13 +1,16 @@ """Unit tests for LLM multimodal functionality across all providers.""" +import asyncio import base64 +import json +import logging import os from unittest.mock import patch import pytest from crewai.llm import LLM -from crewai_files import ImageFile, PDFFile, TextFile, format_multimodal_content +from crewai_files import File, ImageFile, PDFFile, TextFile, format_multimodal_content # Check for optional provider dependencies try: @@ -372,4 +375,168 @@ class TestMultipleFilesFormatting: result = format_multimodal_content({}, llm.model) - assert result == [] \ No newline at end of file + assert result == [] + + +class TestFilesStrippedWhenMultimodalUnsupported: + """Tests that File objects are always stripped from messages before API call. + + Regression tests for https://github.com/crewAIInc/crewAI/issues/4498 + TypeError: Object of type File is not JSON serializable + """ + + def test_base_llm_strips_files_when_multimodal_not_supported(self) -> None: + """BaseLLM._process_message_files must remove files when multimodal is off.""" + from crewai.llms.base_llm import BaseLLM + + class NonMultimodalLLM(BaseLLM): + def call(self, messages, tools=None, callbacks=None): + return "test" + + llm = NonMultimodalLLM(model="test-no-multimodal") + assert llm.supports_multimodal() is False + + messages = [ + { + "role": "user", + "content": "Describe this file", + "files": {"doc": File(source=MINIMAL_PDF)}, + } + ] + + result = llm._process_message_files(messages) + + assert "files" not in result[0] + assert result[0]["content"] == "Describe this file" + + def test_base_llm_strips_files_logs_warning(self, caplog) -> None: + """BaseLLM logs a warning when dropping files on non-multimodal models.""" + from crewai.llms.base_llm import BaseLLM + + class NonMultimodalLLM(BaseLLM): + def call(self, messages, tools=None, callbacks=None): + return "test" + + llm = NonMultimodalLLM(model="test-no-multimodal") + messages = [ + { + "role": "user", + "content": "Describe this", + "files": {"img": ImageFile(source=MINIMAL_PNG)}, + } + ] + + with caplog.at_level(logging.WARNING): + llm._process_message_files(messages) + + assert any("dropped from the request" in r.message for r in caplog.records) + + def test_litellm_strips_files_when_multimodal_not_supported(self) -> None: + """LLM (litellm wrapper) strips files for non-multimodal models.""" + llm = LLM(model="gpt-3.5-turbo", is_litellm=True) + assert llm.supports_multimodal() is False + + messages = [ + { + "role": "user", + "content": "Describe this file", + "files": {"doc": File(source=MINIMAL_PDF)}, + } + ] + + result = llm._process_message_files(messages) + + assert "files" not in result[0] + assert result[0]["content"] == "Describe this file" + + def test_litellm_async_strips_files_when_multimodal_not_supported(self) -> None: + """LLM async path strips files for non-multimodal models.""" + llm = LLM(model="gpt-3.5-turbo", is_litellm=True) + assert llm.supports_multimodal() is False + + messages = [ + { + "role": "user", + "content": "Describe this file", + "files": {"doc": File(source=MINIMAL_PDF)}, + } + ] + + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete( + llm._aprocess_message_files(messages) + ) + finally: + loop.close() + + assert "files" not in result[0] + assert result[0]["content"] == "Describe this file" + + def test_openai_native_strips_files_when_multimodal_not_supported(self) -> None: + """OpenAI native provider strips files for non-vision models.""" + llm = LLM(model="openai/gpt-3.5-turbo") + assert llm.supports_multimodal() is False + + messages = [ + { + "role": "user", + "content": "Describe this file", + "files": {"doc": File(source=MINIMAL_PDF)}, + } + ] + + result = llm._process_message_files(messages) + + assert "files" not in result[0] + + def test_messages_are_json_serializable_after_file_stripping(self) -> None: + """After _process_message_files, messages must be JSON serializable.""" + llm = LLM(model="gpt-3.5-turbo", is_litellm=True) + + messages = [ + { + "role": "user", + "content": "Analyze this", + "files": {"my_file": File(source=MINIMAL_PDF)}, + } + ] + + result = llm._process_message_files(messages) + + json.dumps(result) + + def test_multiple_messages_all_stripped(self) -> None: + """All messages with files get stripped, not just the first.""" + llm = LLM(model="gpt-3.5-turbo", is_litellm=True) + + messages = [ + { + "role": "user", + "content": "First", + "files": {"a": File(source=MINIMAL_PDF)}, + }, + { + "role": "user", + "content": "Second", + "files": {"b": ImageFile(source=MINIMAL_PNG)}, + }, + ] + + result = llm._process_message_files(messages) + + for msg in result: + assert "files" not in msg + + def test_messages_without_files_unchanged(self) -> None: + """Messages without files are not modified.""" + llm = LLM(model="gpt-3.5-turbo", is_litellm=True) + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + ] + + result = llm._process_message_files(messages) + + assert result == messages