fix(hosted_vllm): stop rewriting the caller's video file part into video_url in place

hosted_vllm converted a video file part into video_url by assigning into the caller's own content list, so a Router fallback to another provider received a part it does not read and the video was dropped without an error. Build a new content list for the vLLM request instead
This commit is contained in:
sbin 2026-10-07 09:30:42 +09:00
parent a5a86cdda7
commit 6c37fbe4ba
2 changed files with 39 additions and 9 deletions

View file

@ -21,6 +21,7 @@ from litellm.types.llms.openai import (
ChatCompletionToolCallFunctionChunk,
ChatCompletionVideoObject,
ChatCompletionVideoUrlObject,
OpenAIMessageContentListBlock,
)
from ....utils import remove_additional_properties, remove_strict_from_schema
@ -142,6 +143,12 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
return ChatCompletionVideoObject(type="video_url", video_url=ChatCompletionVideoUrlObject(url=file_data))
raise ValueError("file_id or file_data is required")
def _video_file_as_video_url(self, content_item: OpenAIMessageContentListBlock) -> OpenAIMessageContentListBlock:
if content_item.get("type") != "file":
return content_item
file_item: Final = cast(ChatCompletionFileObject, content_item)
return self._convert_file_to_video_url(file_item) if self._is_video_file(file_item) else content_item
@overload
def _transform_messages(
self, messages: list[AllMessageValues], model: str, is_async: Literal[True]
@ -224,14 +231,7 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
elif message["role"] == "user":
message_content = message.get("content")
if message_content and isinstance(message_content, list):
replaced_content_items: list[tuple[int, ChatCompletionFileObject]] = []
for idx, content_item in enumerate(message_content):
if content_item.get("type") == "file":
content_item = cast(ChatCompletionFileObject, content_item)
if self._is_video_file(content_item):
replaced_content_items.append((idx, content_item))
for idx, content_item in replaced_content_items:
message_content[idx] = self._convert_file_to_video_url(content_item)
message["content"] = [self._video_file_as_video_url(item) for item in message_content]
if is_async:
return super()._transform_messages(messages, model, is_async=cast(Literal[True], True))

View file

@ -1,9 +1,11 @@
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
import litellm
from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
@ -43,6 +45,34 @@ def test_hosted_vllm_chat_transformation_file_url():
]
def test_hosted_vllm_video_file_conversion_leaves_caller_messages_untouched():
video_file_part = {"type": "file", "file": {"file_id": "https://example.com/video.mp4", "format": "video/mp4"}}
messages = [{"role": "user", "content": [{"type": "text", "text": "Describe this video"}, video_file_part]}]
with respx.mock() as upstream:
route = upstream.post("http://vllm.test/v1/chat/completions").mock(
return_value=httpx.Response(
200,
json={
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1,
"model": "qwen-vl",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "A cat."}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
)
litellm.completion(model="hosted_vllm/qwen-vl", messages=messages, api_base="http://vllm.test/v1")
assert json.loads(route.calls.last.request.content)["messages"][0]["content"][1] == {
"type": "video_url",
"video_url": {"url": "https://example.com/video.mp4"},
}
assert messages[0]["content"][1] is video_file_part
def test_hosted_vllm_supports_reasoning_effort():
config = HostedVLLMChatConfig()
supported_params = config.get_supported_openai_params(model="hosted_vllm/gpt-oss-120b")