From c5cec60fd053b16077bd5a6f91d2dba2cd1ce7d4 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 19 Feb 2026 16:40:58 -0300 Subject: [PATCH] fix(moonshot): preserve image_url blocks in multimodal messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moonshot's _transform_messages unconditionally flattened content arrays to plain text, dropping image_url blocks. Vision models like kimi-k2.5 accept the standard OpenAI content array format. Now checks for image_url blocks before flattening — if any message contains images the content array is preserved intact. Fixes #20862 --- docs/my-website/docs/providers/moonshot.md | 31 ++++++ litellm/llms/moonshot/chat/transformation.py | 20 +++- .../test_moonshot_chat_transformation.py | 97 ++++++++++++++++++- 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/docs/my-website/docs/providers/moonshot.md b/docs/my-website/docs/providers/moonshot.md index 2e00bae355..827f2fd53c 100644 --- a/docs/my-website/docs/providers/moonshot.md +++ b/docs/my-website/docs/providers/moonshot.md @@ -219,6 +219,37 @@ curl http://localhost:4000/v1/chat/completions \ For more detailed information on using the LiteLLM Proxy, see the [LiteLLM Proxy documentation](../providers/litellm_proxy). +## Image / Vision Support + +Moonshot vision models (`kimi-k2.5`, `kimi-latest`, `moonshot-v1-*-vision-preview`, etc.) accept the standard OpenAI content array with `image_url` blocks. + +LiteLLM automatically detects when your messages contain images and preserves the content array so the image payload reaches the Moonshot API. For text-only requests the content is flattened to a plain string, as required by Moonshot text models. + +```python showLineNumbers title="Moonshot Vision Example" +import os +import litellm + +os.environ["MOONSHOT_API_KEY"] = "" + +response = litellm.completion( + model="moonshot/kimi-k2.5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ], +) + +print(response.choices[0].message.content) +``` + ## Moonshot AI Limitations & LiteLLM Handling LiteLLM automatically handles the following [Moonshot AI limitations](https://platform.moonshot.ai/docs/guide/migrating-from-openai-to-kimi#about-api-compatibility) to provide seamless OpenAI compatibility: diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 0e78e58c7f..72c51bf74f 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -33,9 +33,25 @@ class MoonshotChatConfig(OpenAIGPTConfig): self, messages: List[AllMessageValues], model: str, is_async: bool = False ) -> Union[List[AllMessageValues], Coroutine[Any, Any, List[AllMessageValues]]]: """ - Moonshot AI does not support content in list format. + Moonshot text-only models don't support content in list format. + Multimodal models (kimi-k2.5, kimi-latest, etc.) accept the + standard OpenAI content array with non-text blocks (image_url, + input_audio, video_url, file, etc.). + + If any message contains a non-text content part, skip flattening + so the multimodal payload is preserved. """ - messages = handle_messages_with_content_list_to_str_conversion(messages) + has_non_text = False + for m in messages: + _content = m.get("content") + if _content and isinstance(_content, list): + if any(c.get("type") != "text" for c in _content): + has_non_text = True + break + + if not has_non_text: + messages = handle_messages_with_content_list_to_str_conversion(messages) + if is_async: return super()._transform_messages( messages=messages, model=model, is_async=True diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py index 62fcec04c1..345186e8a6 100644 --- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py @@ -309,4 +309,99 @@ class TestMoonshotConfig: # Check that no extra message was added assert len(result["messages"]) == 1 - assert result["messages"][0]["content"] == "What's the weather?" \ No newline at end of file + assert result["messages"][0]["content"] == "What's the weather?" + + def test_transform_messages_preserves_image_url_content(self): + """Test that messages with image_url blocks are NOT flattened to strings. + + Multimodal models like kimi-k2.5 accept the standard OpenAI content + array with non-text blocks. When any message contains a non-text part, + the content array must be preserved so the payload reaches the API. + """ + config = MoonshotChatConfig() + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ] + + result = config.transform_request( + model="kimi-k2.5", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + # Content must remain a list (not flattened to a string) + assert isinstance(result["messages"][0]["content"], list) + assert len(result["messages"][0]["content"]) == 2 + assert result["messages"][0]["content"][0]["type"] == "text" + assert result["messages"][0]["content"][1]["type"] == "image_url" + + def test_transform_messages_preserves_non_text_content(self): + """Test that any non-text content type (input_audio, video_url, file, + etc.) also prevents flattening, matching the OpenAI content spec.""" + config = MoonshotChatConfig() + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Transcribe this audio"}, + { + "type": "input_audio", + "input_audio": {"data": "base64data", "format": "wav"}, + }, + ], + } + ] + + result = config.transform_request( + model="kimi-k2.5", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert isinstance(result["messages"][0]["content"], list) + assert len(result["messages"][0]["content"]) == 2 + assert result["messages"][0]["content"][1]["type"] == "input_audio" + + def test_transform_messages_flattens_text_only_content(self): + """Test that text-only content arrays ARE flattened to strings. + + For text-only requests, Moonshot expects plain string content. + The content list should be converted to a single string. + """ + config = MoonshotChatConfig() + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello, how are you?"}, + ], + } + ] + + result = config.transform_request( + model="moonshot-v1-8k", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + # Content should be flattened to a plain string + assert isinstance(result["messages"][0]["content"], str) + assert result["messages"][0]["content"] == "Hello, how are you?" \ No newline at end of file