fix(moonshot): preserve image_url blocks in multimodal messages

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
This commit is contained in:
Chesars
2026-02-19 16:44:38 -03:00
parent bac1b6b2e0
commit c5cec60fd0
3 changed files with 145 additions and 3 deletions
@@ -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:
+18 -2
View File
@@ -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
@@ -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?"
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?"