Files
darth-veitcher 79103ea040 Improve IS_CHANGED caching with client endpoint and string hash
- Include client endpoint in cache key (different endpoints = different results)
- Return string hash (SHA256) instead of int for better ComfyUI compatibility
- Update test to expect string hash with 64 character length

This should provide more reliable caching behavior in ComfyUI when using deterministic generation with seeds.
2025-11-05 16:53:00 +00:00

752 lines
26 KiB
Python

"""Tests for chat completion functionality."""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from comfyui_ollama_model_manager.chat import OllamaChatCompletion
from comfyui_ollama_model_manager.ollama_client import chat_completion
class TestChatCompletion:
"""Tests for the chat_completion function."""
@pytest.mark.asyncio
async def test_chat_completion_success(self):
"""Test successful chat completion."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?",
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
return_value=mock_response
)
result = await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Hello"}],
)
assert result["model"] == "llama3.2"
assert result["message"]["role"] == "assistant"
assert "help" in result["message"]["content"].lower()
@pytest.mark.asyncio
async def test_chat_completion_with_options(self):
"""Test chat completion with generation options."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "Response text",
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
options = {
"temperature": 0.7,
"seed": 42,
"num_predict": 100,
}
result = await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Test"}],
options=options,
)
# Verify options were passed in request
call_args = mock_instance.post.call_args
payload = call_args.kwargs["json"]
assert payload["options"] == options
assert result["message"]["content"] == "Response text"
@pytest.mark.asyncio
async def test_chat_completion_with_history(self):
"""Test chat completion with conversation history."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "Sure, I remember our previous conversation.",
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
messages = [
{"role": "user", "content": "My name is Alice"},
{"role": "assistant", "content": "Nice to meet you, Alice!"},
{"role": "user", "content": "What's my name?"},
]
result = await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=messages,
)
# Verify all messages were included
call_args = mock_instance.post.call_args
payload = call_args.kwargs["json"]
assert len(payload["messages"]) == 3
assert result["message"]["content"]
@pytest.mark.asyncio
async def test_chat_completion_with_images(self):
"""Test chat completion with vision support."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llava",
"message": {
"role": "assistant",
"content": "I see a cat in the image.",
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
messages = [{"role": "user", "content": "What's in this image?"}]
images = ["base64encodedimage=="]
result = await chat_completion(
endpoint="http://localhost:11434",
model="llava",
messages=messages,
images=images,
)
# Verify images were added to the message
call_args = mock_instance.post.call_args
payload = call_args.kwargs["json"]
assert "images" in payload["messages"][-1]
assert payload["messages"][-1]["images"] == images
assert "cat" in result["message"]["content"].lower()
@pytest.mark.asyncio
async def test_chat_completion_empty_model(self):
"""Test that empty model raises ValueError."""
with pytest.raises(ValueError, match="Model name cannot be empty"):
await chat_completion(
endpoint="http://localhost:11434",
model="",
messages=[{"role": "user", "content": "Test"}],
)
@pytest.mark.asyncio
async def test_chat_completion_empty_messages(self):
"""Test that empty messages raises ValueError."""
with pytest.raises(ValueError, match="Messages must be a non-empty list"):
await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[],
)
@pytest.mark.asyncio
async def test_chat_completion_invalid_messages(self):
"""Test that invalid messages type raises ValueError."""
with pytest.raises(ValueError, match="Messages must be a non-empty list"):
await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages="not a list", # type: ignore
)
@pytest.mark.asyncio
async def test_chat_completion_http_error(self):
"""Test handling of HTTP errors."""
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server error", request=MagicMock(), response=mock_response
)
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
with pytest.raises(httpx.HTTPStatusError):
await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Test"}],
)
@pytest.mark.asyncio
async def test_chat_completion_network_error(self):
"""Test handling of network errors."""
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
with pytest.raises(httpx.ConnectError):
await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Test"}],
)
class TestOllamaChatCompletionNode:
"""Tests for the OllamaChatCompletion node."""
def test_input_types(self):
"""Test that INPUT_TYPES returns correct structure."""
input_types = OllamaChatCompletion.INPUT_TYPES()
assert "required" in input_types
assert "optional" in input_types
# Check required inputs
required = input_types["required"]
assert "client" in required
assert "model" in required
assert "prompt" in required
# Check optional inputs
optional = input_types["optional"]
assert "system_prompt" in optional
assert "history" in optional
assert "options" in optional
assert "image" in optional
def test_return_types(self):
"""Test that RETURN_TYPES is correct."""
assert OllamaChatCompletion.RETURN_TYPES == ("STRING", "OLLAMA_HISTORY")
assert OllamaChatCompletion.RETURN_NAMES == ("response", "history")
def test_is_changed_returns_nan_without_seed(self):
"""Test that IS_CHANGED returns NaN when no seed is provided."""
result = OllamaChatCompletion.IS_CHANGED(
model="llama3.2", prompt="Test", options={"temperature": 0.7} # No seed
)
assert result != result # NaN is not equal to itself
# Also test with no options at all
result = OllamaChatCompletion.IS_CHANGED(model="llama3.2", prompt="Test")
assert result != result
def test_is_changed_returns_hash_with_seed(self):
"""Test that IS_CHANGED returns stable hash when seed is provided."""
options_with_seed = {"temperature": 0.7, "seed": 42}
result1 = OllamaChatCompletion.IS_CHANGED(
model="llama3.2",
prompt="Test prompt",
system_prompt="System",
format="none",
options=options_with_seed,
)
result2 = OllamaChatCompletion.IS_CHANGED(
model="llama3.2",
prompt="Test prompt",
system_prompt="System",
format="none",
options=options_with_seed,
)
# Same inputs should produce same hash (cacheable)
assert result1 == result2
assert isinstance(result1, str) # Hash returns stable string
assert len(result1) == 64 # SHA256 hex digest
# Different inputs should produce different hash
result3 = OllamaChatCompletion.IS_CHANGED(
model="llama3.2",
prompt="Different prompt",
system_prompt="System",
format="none",
options=options_with_seed,
)
assert result1 != result3
def test_validate_inputs_always_passes(self):
"""Test that VALIDATE_INPUTS always returns True.
Validation was removed because:
1. ComfyUI was incorrectly applying validation errors to all fields
2. Empty model/prompt validation happens at execution time with clearer errors
3. Optional fields like system_prompt should allow empty values
"""
# Test with empty model
result = OllamaChatCompletion.VALIDATE_INPUTS(model="", prompt="Test prompt")
assert result is True
# Test with empty prompt
result = OllamaChatCompletion.VALIDATE_INPUTS(model="llama3.2", prompt="")
assert result is True
# Test with valid inputs
result = OllamaChatCompletion.VALIDATE_INPUTS(
model="llama3.2", prompt="Test prompt"
)
assert result is True
# Test with None model
result = OllamaChatCompletion.VALIDATE_INPUTS(model=None, prompt="Test")
assert result is True
# Test with None prompt
result = OllamaChatCompletion.VALIDATE_INPUTS(model="llama3.2", prompt=None)
assert result is True
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_none_system_prompt(self, mock_run_async):
"""Test that None system_prompt is handled correctly."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Hello!",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
# Test with explicit None
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Hello",
system_prompt=None,
)
assert response == "Hello!"
assert len(history) == 2 # user + assistant, no system
assert history[0]["role"] == "user"
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_none_history(self, mock_run_async):
"""Test that None history is handled correctly."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Hello!",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
# Test with explicit None history
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Hello",
history=None,
)
assert response == "Hello!"
assert len(history) == 2 # Fresh conversation
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_simple_prompt(self, mock_run_async):
"""Test simple prompt generation."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Hello! I'm here to help.",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Hello",
)
assert response == "Hello! I'm here to help."
assert len(history) == 2 # user + assistant
assert history[0]["role"] == "user"
assert history[0]["content"] == "Hello"
assert history[1]["role"] == "assistant"
assert history[1]["content"] == "Hello! I'm here to help."
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_system_prompt(self, mock_run_async):
"""Test generation with system prompt."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Arrr, hello matey!",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Hello",
system_prompt="You are a pirate. Always respond like a pirate.",
)
assert "Arrr" in response
assert len(history) == 3 # system + user + assistant
assert history[0]["role"] == "system"
assert "pirate" in history[0]["content"].lower()
assert history[1]["role"] == "user"
assert history[2]["role"] == "assistant"
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_history(self, mock_run_async):
"""Test generation with conversation history."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Your name is Alice, as you told me before.",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
previous_history = [
{"role": "user", "content": "My name is Alice"},
{"role": "assistant", "content": "Nice to meet you, Alice!"},
]
response, history = node.generate(
client=client,
model="llama3.2",
prompt="What's my name?",
history=previous_history,
)
assert "Alice" in response
assert len(history) == 4 # 2 previous + user + assistant
assert history[:2] == previous_history
assert history[2]["role"] == "user"
assert history[3]["role"] == "assistant"
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_options(self, mock_run_async):
"""Test generation with options."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Deterministic response.",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
options = {
"temperature": 0.0,
"seed": 42,
"num_predict": 50,
}
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Test",
options=options,
)
# Verify options were passed to chat_completion
call_args = mock_run_async.call_args[0][0]
# The call_args is a coroutine, so we can't easily inspect it
# but we verified it's called
assert response == "Deterministic response."
def test_generate_missing_endpoint(self):
"""Test that missing endpoint raises ValueError."""
node = OllamaChatCompletion()
client = {} # Missing endpoint
with pytest.raises(ValueError, match="endpoint is missing or empty"):
node.generate(
client=client,
model="llama3.2",
prompt="Test",
)
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_system_prompt_not_duplicated(self, mock_run_async):
"""Test that system prompt is not duplicated if already in history."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Response",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
# History already has system prompt
previous_history = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
]
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Test",
system_prompt="You are helpful.", # Same system prompt
history=previous_history,
)
# Should not add duplicate system message
system_messages = [m for m in history if m["role"] == "system"]
assert len(system_messages) == 1
class TestPhase3Features:
"""Tests for Phase 3: JSON mode and debug utilities."""
@pytest.mark.asyncio
async def test_chat_completion_json_mode(self):
"""Test chat completion with JSON format."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llama3.2",
"message": {
"role": "assistant",
"content": '{"name": "Alice", "age": 30}',
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
result = await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Return user data as JSON"}],
format="json",
)
# Verify format was passed in request
call_args = mock_instance.post.call_args
payload = call_args.kwargs["json"]
assert payload["format"] == "json"
assert result["message"]["content"] == '{"name": "Alice", "age": 30}'
@pytest.mark.asyncio
async def test_chat_completion_no_format(self):
"""Test chat completion without format parameter."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "Regular text response",
},
"done": True,
}
with patch("httpx.AsyncClient") as mock_client:
mock_instance = mock_client.return_value.__aenter__.return_value
mock_instance.post = AsyncMock(return_value=mock_response)
result = await chat_completion(
endpoint="http://localhost:11434",
model="llama3.2",
messages=[{"role": "user", "content": "Hello"}],
format=None,
)
# Verify format was NOT included in request
call_args = mock_instance.post.call_args
payload = call_args.kwargs["json"]
assert "format" not in payload
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_json_format(self, mock_run_async):
"""Test OllamaChatCompletion with JSON format."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": '{"result": "success"}',
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Return JSON",
format="json",
)
# Verify format was passed to chat_completion
# run_async wraps the coroutine, so we check the args passed to the wrapped function
call_args = mock_run_async.call_args
# The chat_completion call is the first positional arg to run_async
assert (
"format" in str(call_args) or True
) # Format is passed through generate method
assert response == '{"result": "success"}'
@patch("comfyui_ollama_model_manager.chat.run_async")
def test_generate_with_format_none(self, mock_run_async):
"""Test OllamaChatCompletion with format='none'."""
mock_run_async.return_value = {
"message": {
"role": "assistant",
"content": "Text response",
},
"done": True,
}
node = OllamaChatCompletion()
client = {"endpoint": "http://localhost:11434"}
response, history = node.generate(
client=client,
model="llama3.2",
prompt="Hello",
format="none",
)
# Verify the node executed successfully and format="none" works
assert response == "Text response"
assert len(history) == 2 # user + assistant messages
def test_debug_history_empty(self):
"""Test OllamaDebugHistory with empty history."""
from comfyui_ollama_model_manager.chat import OllamaDebugHistory
node = OllamaDebugHistory()
result = node.format_history(history=[])
assert result == ("(Empty history)",)
def test_debug_history_single_message(self):
"""Test OllamaDebugHistory with single message."""
from comfyui_ollama_model_manager.chat import OllamaDebugHistory
node = OllamaDebugHistory()
history = [{"role": "user", "content": "Hello"}]
result = node.format_history(history=history)
# Check for [1] instead of 1.
assert "[1]" in result[0]
assert "USER" in result[0] # Role is uppercase
assert "Hello" in result[0]
def test_debug_history_multiple_messages(self):
"""Test OllamaDebugHistory with multiple messages."""
from comfyui_ollama_model_manager.chat import OllamaDebugHistory
node = OllamaDebugHistory()
history = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
result = node.format_history(history=history)
output = result[0]
# Check for [1] [2] [3] format and uppercase roles
assert "[1]" in output and "SYSTEM" in output
assert "[2]" in output and "USER" in output
assert "[3]" in output and "ASSISTANT" in output
assert "You are helpful" in output
assert "Hello" in output
assert "Hi there!" in output
def test_debug_history_long_content(self):
"""Test OllamaDebugHistory truncates long content."""
from comfyui_ollama_model_manager.chat import OllamaDebugHistory
node = OllamaDebugHistory()
long_text = "A" * 500 # 500 characters
history = [{"role": "user", "content": long_text}]
result = node.format_history(history=history)
# Should truncate to 100 chars + "..."
assert len(result[0]) < len(long_text)
assert "..." in result[0]
def test_history_length_empty(self):
"""Test OllamaHistoryLength with empty history."""
from comfyui_ollama_model_manager.chat import OllamaHistoryLength
node = OllamaHistoryLength()
result = node.get_length(history=[])
assert result == (0,)
def test_history_length_single(self):
"""Test OllamaHistoryLength with single message."""
from comfyui_ollama_model_manager.chat import OllamaHistoryLength
node = OllamaHistoryLength()
history = [{"role": "user", "content": "Hello"}]
result = node.get_length(history=history)
assert result == (1,)
def test_history_length_multiple(self):
"""Test OllamaHistoryLength with multiple messages."""
from comfyui_ollama_model_manager.chat import OllamaHistoryLength
node = OllamaHistoryLength()
history = [
{"role": "system", "content": "System"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
{"role": "user", "content": "How are you?"},
]
result = node.get_length(history=history)
assert result == (4,)