mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-07-21 10:05:26 -04:00
86f669dc82
- Vision Support (Issue #45): CLI + Server with OpenAI-compatible image API, EXIF metadata - Unix Pipes (ADR-014): stdin support, isatty detection, SIGPIPE handling - Memory-Aware Loading (ADR-016): Pre-load checks with >70% RAM warnings - Python 3.9-3.14: Full compatibility verified (476-485 tests passing) - Fixed: --log-json regression (Issue #44), Vision multimodal history filtering See CHANGELOG.md for complete details.
217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
"""
|
|
Server Vision API tests (ADR-012 Phase 3).
|
|
|
|
Tests for vision-specific server functionality:
|
|
- Image detection in requests
|
|
- Streaming rejection for vision
|
|
- Message format handling
|
|
- Vision request routing
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
|
|
from mlxk2.core.server_base import (
|
|
app,
|
|
ChatMessage,
|
|
ChatCompletionRequest,
|
|
_request_has_images,
|
|
_messages_to_dicts,
|
|
_extract_text_from_messages,
|
|
)
|
|
|
|
|
|
class TestChatMessageModel:
|
|
"""Tests for ChatMessage with Vision content support."""
|
|
|
|
def test_string_content(self):
|
|
"""ChatMessage should accept string content."""
|
|
msg = ChatMessage(role="user", content="Hello")
|
|
assert msg.content == "Hello"
|
|
|
|
def test_list_content_text_only(self):
|
|
"""ChatMessage should accept list content with text."""
|
|
content = [{"type": "text", "text": "What is this?"}]
|
|
msg = ChatMessage(role="user", content=content)
|
|
assert msg.content == content
|
|
|
|
def test_list_content_with_image(self):
|
|
"""ChatMessage should accept list content with image_url."""
|
|
content = [
|
|
{"type": "text", "text": "What is this?"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4A..."}}
|
|
]
|
|
msg = ChatMessage(role="user", content=content)
|
|
assert len(msg.content) == 2
|
|
assert msg.content[1]["type"] == "image_url"
|
|
|
|
def test_invalid_role_rejected(self):
|
|
"""ChatMessage should reject invalid roles."""
|
|
with pytest.raises(ValidationError):
|
|
ChatMessage(role="invalid", content="Hello")
|
|
|
|
|
|
class TestRequestHasImages:
|
|
"""Tests for _request_has_images() helper."""
|
|
|
|
def test_string_content_no_images(self):
|
|
"""String content should not have images."""
|
|
messages = [ChatMessage(role="user", content="Hello")]
|
|
assert _request_has_images(messages) is False
|
|
|
|
def test_list_content_text_only_no_images(self):
|
|
"""List content with text only should not have images."""
|
|
messages = [
|
|
ChatMessage(role="user", content=[{"type": "text", "text": "Hello"}])
|
|
]
|
|
assert _request_has_images(messages) is False
|
|
|
|
def test_list_content_with_image(self):
|
|
"""List content with image_url should detect images."""
|
|
messages = [
|
|
ChatMessage(role="user", content=[
|
|
{"type": "text", "text": "What is this?"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
|
|
])
|
|
]
|
|
assert _request_has_images(messages) is True
|
|
|
|
def test_multiple_messages_with_image_in_second(self):
|
|
"""Should detect image in any message."""
|
|
messages = [
|
|
ChatMessage(role="system", content="You are helpful."),
|
|
ChatMessage(role="user", content=[
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
|
|
])
|
|
]
|
|
assert _request_has_images(messages) is True
|
|
|
|
def test_empty_list_content(self):
|
|
"""Empty list content should not have images."""
|
|
messages = [ChatMessage(role="user", content=[])]
|
|
assert _request_has_images(messages) is False
|
|
|
|
|
|
class TestMessagesToDicts:
|
|
"""Tests for _messages_to_dicts() helper."""
|
|
|
|
def test_converts_string_content(self):
|
|
"""Should convert string content to dict format."""
|
|
messages = [ChatMessage(role="user", content="Hello")]
|
|
result = _messages_to_dicts(messages)
|
|
assert result == [{"role": "user", "content": "Hello"}]
|
|
|
|
def test_converts_list_content(self):
|
|
"""Should preserve list content in dict format."""
|
|
content = [{"type": "text", "text": "Hello"}]
|
|
messages = [ChatMessage(role="user", content=content)]
|
|
result = _messages_to_dicts(messages)
|
|
assert result == [{"role": "user", "content": content}]
|
|
|
|
|
|
class TestExtractTextFromMessages:
|
|
"""Tests for _extract_text_from_messages() helper."""
|
|
|
|
def test_extracts_string_content(self):
|
|
"""Should extract text from string content."""
|
|
messages = [ChatMessage(role="user", content="Hello world")]
|
|
result = _extract_text_from_messages(messages)
|
|
assert result == "Hello world"
|
|
|
|
def test_extracts_from_list_content(self):
|
|
"""Should extract text items from list content."""
|
|
messages = [
|
|
ChatMessage(role="user", content=[
|
|
{"type": "text", "text": "What is in this image?"},
|
|
{"type": "image_url", "image_url": {"url": "data:..."}}
|
|
])
|
|
]
|
|
result = _extract_text_from_messages(messages)
|
|
assert result == "What is in this image?"
|
|
|
|
def test_combines_multiple_messages(self):
|
|
"""Should combine text from multiple messages."""
|
|
messages = [
|
|
ChatMessage(role="system", content="You are helpful."),
|
|
ChatMessage(role="user", content="What is 2+2?")
|
|
]
|
|
result = _extract_text_from_messages(messages)
|
|
assert "You are helpful." in result
|
|
assert "What is 2+2?" in result
|
|
|
|
|
|
class TestVisionStreamingGracefulDegradation:
|
|
"""Tests for graceful degradation with vision streaming requests."""
|
|
|
|
def test_vision_streaming_graceful_degradation(self):
|
|
"""Vision request with stream=True should gracefully degrade to batch (not 400)."""
|
|
# Note: With graceful degradation, stream=true is silently ignored
|
|
# The server proceeds with batch mode instead of returning an error
|
|
client = TestClient(app)
|
|
|
|
# Vision request format with image
|
|
payload = {
|
|
"model": "test/vision-model",
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What is this?"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="}}
|
|
]
|
|
}],
|
|
"stream": True,
|
|
}
|
|
|
|
# Mock to avoid actual model loading - will fail on model not found, not streaming
|
|
with patch('mlxk2.core.server_base._request_has_images', return_value=True):
|
|
resp = client.post("/v1/chat/completions", json=payload)
|
|
|
|
# Should NOT be 400 with "streaming" error - graceful degradation
|
|
# Will be 404 (model not found) or similar, but not streaming rejection
|
|
if resp.status_code == 400:
|
|
assert "streaming" not in resp.text.lower(), "Should not reject due to streaming"
|
|
|
|
|
|
class TestTextPathUnaffected:
|
|
"""Tests to ensure text path is not affected by vision changes."""
|
|
|
|
def test_text_request_uses_text_handler(self):
|
|
"""Text-only request should use text handler path."""
|
|
client = TestClient(app)
|
|
|
|
mock_runner = Mock()
|
|
mock_runner.generate_batch.return_value = "Response"
|
|
mock_runner._format_conversation.return_value = "User: Hello\n\nAssistant:"
|
|
|
|
with patch('mlxk2.core.server_base.get_or_load_model', return_value=mock_runner):
|
|
payload = {
|
|
"model": "test/text-model",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"stream": False,
|
|
}
|
|
resp = client.post("/v1/chat/completions", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert mock_runner.generate_batch.called
|
|
|
|
def test_text_request_streaming_works(self):
|
|
"""Text request with streaming should work normally."""
|
|
client = TestClient(app)
|
|
|
|
mock_runner = Mock()
|
|
mock_runner.generate_streaming.return_value = iter(["Hello", " world"])
|
|
mock_runner._format_conversation.return_value = "User: Hi\n\nAssistant:"
|
|
|
|
with patch('mlxk2.core.server_base.get_or_load_model', return_value=mock_runner):
|
|
payload = {
|
|
"model": "test/text-model",
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"stream": True,
|
|
}
|
|
resp = client.post("/v1/chat/completions", json=payload)
|
|
|
|
# Streaming should return 200
|
|
assert resp.status_code == 200
|