From d6d584f2d799bd4ccd00eb73b35542035d10ae84 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Mon, 26 Oct 2026 15:08:27 -0400 Subject: [PATCH 1/2] fix(cloudflare): flatten content-part message arrays to a string Cloudflare Workers AI rejects message content that is an array, so requests using OpenAI content parts failed with a type mismatch. Flatten them in _transform_messages, the way the other string-only providers do. --- .../llms/cloudflare/chat/transformation.py | 33 +++++++++- .../test_cloudflare_transformation.py | 66 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/litellm/llms/cloudflare/chat/transformation.py b/litellm/llms/cloudflare/chat/transformation.py index 2bbfd2010cc..b32215320c9 100644 --- a/litellm/llms/cloudflare/chat/transformation.py +++ b/litellm/llms/cloudflare/chat/transformation.py @@ -1,8 +1,12 @@ -from typing import Final +from collections.abc import Coroutine +from typing import Final, Literal, overload import httpx from litellm._logging import verbose_logger +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + handle_messages_with_content_list_to_str_conversion, +) from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig @@ -87,6 +91,33 @@ class CloudflareChatConfig(OpenAIGPTConfig): api_base=api_base, ) + @overload + def _transform_messages( + self, + messages: list[AllMessageValues], # mutable-ok: base signature + model: str, + is_async: Literal[True], + ) -> Coroutine[object, object, list[AllMessageValues]]: ... # mutable-ok: base signature + + @overload + def _transform_messages( + self, + messages: list[AllMessageValues], # mutable-ok: base signature + model: str, + is_async: Literal[False] = False, + ) -> list[AllMessageValues]: ... # mutable-ok: base signature + + def _transform_messages( + self, + messages: list[AllMessageValues], # mutable-ok: base signature + model: str, + is_async: bool = False, + ) -> list[AllMessageValues] | Coroutine[object, object, list[AllMessageValues]]: # mutable-ok: base signature + flattened: Final = handle_messages_with_content_list_to_str_conversion(messages) + if is_async: + return super()._transform_messages(messages=flattened, model=model, is_async=True) + return super()._transform_messages(messages=flattened, model=model, is_async=False) + def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException: return CloudflareError( status_code=status_code, diff --git a/tests/unit/llms/cloudflare/test_cloudflare_transformation.py b/tests/unit/llms/cloudflare/test_cloudflare_transformation.py index 3a45c83556c..1b52e6a717d 100644 --- a/tests/unit/llms/cloudflare/test_cloudflare_transformation.py +++ b/tests/unit/llms/cloudflare/test_cloudflare_transformation.py @@ -205,6 +205,72 @@ def test_validate_environment_sets_bearer_and_content_type(): assert headers["Content-Type"] == "application/json" +def test_transform_request_flattens_content_part_arrays_to_string(): + config = CloudflareChatConfig() + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "More text"}, + ], + } + ] + + body = config.transform_request( + model="@cf/meta/llama-2-7b-chat-int8", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert body["messages"][0]["content"] == "HelloMore text" + + +def test_transform_request_leaves_string_content_untouched(): + config = CloudflareChatConfig() + messages = [ + {"role": "system", "content": "you are terse"}, + {"role": "user", "content": "weather in nyc?"}, + ] + + body = config.transform_request( + model="@cf/meta/llama-2-7b-chat-int8", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert [m["content"] for m in body["messages"]] == ["you are terse", "weather in nyc?"] + + +def test_async_transform_request_flattens_content_part_arrays_to_string(): + config = CloudflareChatConfig() + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "More text"}, + ], + } + ] + + body = asyncio.run( + config.async_transform_request( + model="@cf/meta/llama-2-7b-chat-int8", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + ) + + assert body["messages"][0]["content"] == "HelloMore text" + + def _chat_response() -> dict[str, object]: return { "id": "chatcmpl-cf", From bbf9021189017b70a837cdebce1f3836916ea732 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Thu, 5 Nov 2026 16:32:35 -0800 Subject: [PATCH 2/2] fix(cloudflare): keep image parts when flattening message content Flattening content parts to a string keeps only the text, so a message with an image_url would lose its image on vision models like llama-3.2-11b-vision-instruct. Skip the flatten when any message carries an image or audio part, using the existing audio_or_image_in_message_content helper. --- litellm/llms/cloudflare/chat/transformation.py | 5 ++++- .../test_cloudflare_transformation.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/litellm/llms/cloudflare/chat/transformation.py b/litellm/llms/cloudflare/chat/transformation.py index b32215320c9..acc7609a36c 100644 --- a/litellm/llms/cloudflare/chat/transformation.py +++ b/litellm/llms/cloudflare/chat/transformation.py @@ -5,6 +5,7 @@ import httpx from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( + audio_or_image_in_message_content, handle_messages_with_content_list_to_str_conversion, ) from litellm.litellm_core_utils.url_utils import join_api_base @@ -113,7 +114,9 @@ class CloudflareChatConfig(OpenAIGPTConfig): model: str, is_async: bool = False, ) -> list[AllMessageValues] | Coroutine[object, object, list[AllMessageValues]]: # mutable-ok: base signature - flattened: Final = handle_messages_with_content_list_to_str_conversion(messages) + # Flattening keeps only the text, so skip it when any message carries an image or audio part + has_media: Final = any(audio_or_image_in_message_content(message) for message in messages) + flattened: Final = messages if has_media else handle_messages_with_content_list_to_str_conversion(messages) if is_async: return super()._transform_messages(messages=flattened, model=model, is_async=True) return super()._transform_messages(messages=flattened, model=model, is_async=False) diff --git a/tests/unit/llms/cloudflare/test_cloudflare_transformation.py b/tests/unit/llms/cloudflare/test_cloudflare_transformation.py index 1b52e6a717d..256a3fcde95 100644 --- a/tests/unit/llms/cloudflare/test_cloudflare_transformation.py +++ b/tests/unit/llms/cloudflare/test_cloudflare_transformation.py @@ -246,6 +246,24 @@ def test_transform_request_leaves_string_content_untouched(): assert [m["content"] for m in body["messages"]] == ["you are terse", "weather in nyc?"] +def test_transform_request_does_not_flatten_when_a_part_is_not_text(): + config = CloudflareChatConfig() + parts = [ + {"type": "text", "text": "What is in this picture?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, + ] + + body = config.transform_request( + model="@cf/meta/llama-3.2-11b-vision-instruct", + messages=[{"role": "user", "content": parts}], + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert body["messages"][0]["content"] == parts + + def test_async_transform_request_flattens_content_part_arrays_to_string(): config = CloudflareChatConfig() messages = [