**Core Improvements:** - Fix Pydantic v2 deprecation warning in config.py by migrating to ConfigDict - Add graceful Ctrl-C handling for streaming chat responses - Add connection error handling mid-stream with actionable suggestions - Implement --history-file support for conversation context - Enhance all error messages with specific troubleshooting steps **Chat Command Enhancements:** - Improved SSE streaming with KeyboardInterrupt handling - Added --history-file option to load conversation history from JSON - Support both array and object formats for history files - Better error messages for missing prompts and invalid history files - Graceful handling of connection timeouts during streaming **Error Message Improvements:** - 401: Clear authentication instructions with token expiry hints - 403: Detailed permission error with possible causes - 404: Resource not found with helpful suggestions - 5xx: Server error with admin troubleshooting tips - Network errors: Connection, timeout, and request failures with solutions **Testing:** - Add comprehensive test suite for chat commands (7 tests) - Test streaming and non-streaming responses - Test system prompts, stdin input, and JSON output - Test conversation history loading - Test RAG file and collection context - All 14 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
301 lines
9 KiB
Python
301 lines
9 KiB
Python
"""Tests for chat commands."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from openwebui_cli.main import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class MockStreamResponse:
|
|
"""Mock streaming response for testing."""
|
|
|
|
def __init__(self, lines, status_code=200):
|
|
self.lines = lines
|
|
self.status_code = status_code
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
def iter_lines(self):
|
|
"""Yield lines one by one."""
|
|
for line in self.lines:
|
|
yield line
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config(tmp_path, monkeypatch):
|
|
"""Mock configuration for testing."""
|
|
config_dir = tmp_path / "openwebui"
|
|
config_path = config_dir / "config.yaml"
|
|
|
|
monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir)
|
|
monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path)
|
|
|
|
# Create default config
|
|
from openwebui_cli.config import Config, save_config
|
|
config = Config()
|
|
save_config(config)
|
|
|
|
return config_path
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_keyring(monkeypatch):
|
|
"""Mock keyring for testing."""
|
|
token_store = {}
|
|
|
|
def get_password(service, key):
|
|
return token_store.get(f"{service}:{key}")
|
|
|
|
def set_password(service, key, password):
|
|
token_store[f"{service}:{key}"] = password
|
|
|
|
monkeypatch.setattr("keyring.get_password", get_password)
|
|
monkeypatch.setattr("keyring.set_password", set_password)
|
|
|
|
|
|
def test_chat_send_streaming(mock_config, mock_keyring):
|
|
"""Test streaming chat response."""
|
|
# Mock streaming response
|
|
streaming_lines = [
|
|
'data: {"choices": [{"delta": {"content": "Hello"}}]}',
|
|
'data: {"choices": [{"delta": {"content": " world"}}]}',
|
|
'data: {"choices": [{"delta": {"content": "!"}}]}',
|
|
"data: [DONE]",
|
|
]
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_stream = MockStreamResponse(streaming_lines)
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_http_client.stream.return_value = mock_stream
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "send", "-m", "test-model", "-p", "Hello"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Hello world!" in result.stdout
|
|
|
|
|
|
def test_chat_send_no_stream(mock_config, mock_keyring):
|
|
"""Test non-streaming chat response."""
|
|
response_data = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "This is a test response"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = response_data
|
|
mock_http_client.post.return_value = mock_response
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "send", "-m", "test-model", "-p", "Hello", "--no-stream"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "This is a test response" in result.stdout
|
|
|
|
|
|
def test_chat_send_with_system_prompt(mock_config, mock_keyring):
|
|
"""Test chat with system prompt."""
|
|
response_data = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "Response with system prompt"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = response_data
|
|
mock_http_client.post.return_value = mock_response
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"chat", "send",
|
|
"-m", "test-model",
|
|
"-p", "Hello",
|
|
"-s", "You are a helpful assistant",
|
|
"--no-stream"
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_chat_send_with_history_file(tmp_path, mock_config, mock_keyring):
|
|
"""Test chat with history file."""
|
|
# Create history file
|
|
history_file = tmp_path / "history.json"
|
|
history = [
|
|
{"role": "user", "content": "What is 2+2?"},
|
|
{"role": "assistant", "content": "4"},
|
|
]
|
|
with open(history_file, "w") as f:
|
|
json.dump(history, f)
|
|
|
|
response_data = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "Continuing conversation"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = response_data
|
|
mock_http_client.post.return_value = mock_response
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"chat", "send",
|
|
"-m", "test-model",
|
|
"-p", "What about 3+3?",
|
|
"--history-file", str(history_file),
|
|
"--no-stream"
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_chat_send_stdin(mock_config, mock_keyring):
|
|
"""Test chat with stdin input."""
|
|
response_data = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "Response from stdin"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = response_data
|
|
mock_http_client.post.return_value = mock_response
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "send", "-m", "test-model", "--no-stream"],
|
|
input="Hello from stdin\n",
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_chat_send_json_output(mock_config, mock_keyring):
|
|
"""Test chat with JSON output format."""
|
|
streaming_lines = [
|
|
'data: {"choices": [{"delta": {"content": "Test"}}]}',
|
|
"data: [DONE]",
|
|
]
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_stream = MockStreamResponse(streaming_lines)
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_http_client.stream.return_value = mock_stream
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "send", "-m", "test-model", "-p", "Hello", "--json"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "content" in result.stdout
|
|
|
|
|
|
def test_chat_send_with_rag_context(mock_config, mock_keyring):
|
|
"""Test chat with RAG file and collection context."""
|
|
response_data = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "Response with RAG context"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
with patch("openwebui_cli.commands.chat.create_client") as mock_client:
|
|
mock_http_client = MagicMock()
|
|
mock_http_client.__enter__.return_value = mock_http_client
|
|
mock_http_client.__exit__.return_value = None
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = response_data
|
|
mock_http_client.post.return_value = mock_response
|
|
mock_client.return_value = mock_http_client
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"chat", "send",
|
|
"-m", "test-model",
|
|
"-p", "Search my docs",
|
|
"--file", "file-123",
|
|
"--collection", "coll-456",
|
|
"--no-stream"
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
# Verify the request was made with files context
|
|
call_args = mock_http_client.post.call_args
|
|
assert call_args is not None
|
|
request_body = call_args.kwargs["json"]
|
|
assert "files" in request_body
|
|
assert len(request_body["files"]) == 2
|