From effc55c8e985764f11bc7c468d4d6cddc9651789 Mon Sep 17 00:00:00 2001 From: Miyar <14232275+gakugaku@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:12:35 +0000 Subject: [PATCH] fix(sambanova): keep content lists that carry non-text parts `_transform_messages` flattened every content list to a string, so `image_url` parts were dropped before the request. SambaNova's API accepts the content-list form and its vision models read it, so the model answered as if nothing had been attached, with HTTP 200 and no error. Flatten only text-only lists. --- litellm/llms/sambanova/chat.py | 26 ++++- .../test_litellm/llms/sambanova/test_chat.py | 101 ++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/sambanova/test_chat.py diff --git a/litellm/llms/sambanova/chat.py b/litellm/llms/sambanova/chat.py index 0e7dadf7062..41957b3bffa 100644 --- a/litellm/llms/sambanova/chat.py +++ b/litellm/llms/sambanova/chat.py @@ -14,6 +14,14 @@ from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.types.llms.openai import AllMessageValues +def _has_text_only_content(message: AllMessageValues) -> bool: + """Whether the message content is a string, or a list holding text parts only.""" + content = message.get("content") + if not isinstance(content, list): + return True + return all(isinstance(part, str) or (isinstance(part, dict) and part.get("type") == "text") for part in content) + + class SambanovaConfig(OpenAIGPTConfig): """ Reference: https://docs.sambanova.ai/cloud/api-reference/ @@ -117,14 +125,22 @@ class SambanovaConfig(OpenAIGPTConfig): """ Transform messages to handle content list conversion. - SambaNova API doesn't support content as a list - only string content. - This converts content lists like [{"type": "text", "text": "..."}] to strings. + SambaNova's API takes a string for `content`, and its vision models also take + the OpenAI content-list form with `image_url` parts. Flatten only the messages + whose list is entirely text: flattening a list that carries an `image_url` (or + any other non-text part) drops the attachment, and the model then answers as if + nothing was sent - HTTP 200 and no error, so the loss is silent. """ + def _transform(): + # handle_messages_with_content_list_to_str_conversion mutates the messages it + # is given, so passing the text-only subset converts exactly those. + handle_messages_with_content_list_to_str_conversion([m for m in messages if _has_text_only_content(m)]) + return messages + async def _async_transform(): - return handle_messages_with_content_list_to_str_conversion(messages) + return _transform() if is_async: return _async_transform() - messages = handle_messages_with_content_list_to_str_conversion(messages) - return messages + return _transform() diff --git a/tests/test_litellm/llms/sambanova/test_chat.py b/tests/test_litellm/llms/sambanova/test_chat.py new file mode 100644 index 00000000000..27af33e43b5 --- /dev/null +++ b/tests/test_litellm/llms/sambanova/test_chat.py @@ -0,0 +1,101 @@ +""" +Unit tests for SambaNova chat message transformation +""" + +import pytest + +from litellm.llms.sambanova.chat import SambanovaConfig + + +class TestSambanovaNonTextContentParts: + """ + Content lists that carry a non-text part must keep their list form. + """ + + def test_content_list_with_image_is_preserved(self): + """ + A content list carrying an `image_url` part must NOT be flattened. + + SambaNova's API accepts the OpenAI content-list form with `image_url`, and its + vision models read it. Flattening dropped the image while still returning HTTP + 200, so the model answered as if nothing had been attached. + """ + config = SambanovaConfig() + + image_part = { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}, + } + messages = [ + { + "role": "user", + "content": [{"type": "text", "text": "What colour is this?"}, image_part], + } + ] + + transformed_messages = config._transform_messages( + messages=messages, model="sambanova/gemma-4-31B-it", is_async=False + ) + + content = transformed_messages[0]["content"] + assert isinstance(content, list) + assert content[0] == {"type": "text", "text": "What colour is this?"} + assert content[1] == image_part + + def test_string_content_is_left_alone(self): + """A message whose content is already a string passes through untouched.""" + config = SambanovaConfig() + + messages = [{"role": "user", "content": "Hello"}] + + transformed_messages = config._transform_messages( + messages=messages, model="sambanova/gemma-4-31B-it", is_async=False + ) + + assert transformed_messages[0]["content"] == "Hello" + + def test_text_only_messages_are_still_flattened_alongside_image_messages(self): + """ + Mixed conversation: text-only lists are flattened as before, and only the + message that carries the image keeps its list form. + """ + config = SambanovaConfig() + + messages = [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + { + "role": "user", + "content": [ + {"type": "text", "text": "And this?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}, + ], + }, + ] + + transformed_messages = config._transform_messages( + messages=messages, model="sambanova/gemma-4-31B-it", is_async=False + ) + + assert transformed_messages[0]["content"] == "Hello" + assert isinstance(transformed_messages[1]["content"], list) + + @pytest.mark.asyncio + async def test_async_transform_preserves_image_content(self): + """The async path must behave like the sync one.""" + config = SambanovaConfig() + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What colour is this?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}, + ], + } + ] + + transformed_messages = await config._transform_messages( + messages=messages, model="sambanova/gemma-4-31B-it", is_async=True + ) + + assert isinstance(transformed_messages[0]["content"], list)