diff --git a/openwebui_cli/commands/chat.py b/openwebui_cli/commands/chat.py index 2e1b5c3..9105823 100644 --- a/openwebui_cli/commands/chat.py +++ b/openwebui_cli/commands/chat.py @@ -28,6 +28,7 @@ def send( temperature: float | None = typer.Option(None, "--temperature", "-T", help="Temperature (0.0-2.0)"), max_tokens: int | None = typer.Option(None, "--max-tokens", help="Max response tokens"), json_output: bool = typer.Option(False, "--json", help="Output as JSON"), + history_file: str | None = typer.Option(None, "--history-file", help="Load conversation history from JSON file"), ) -> None: """Send a chat message.""" obj = ctx.obj or {} @@ -41,10 +42,38 @@ def send( console.print("[red]Error: Prompt required. Use -p or pipe input.[/red]") raise typer.Exit(2) - # Build messages + # Load conversation history if provided messages = [] - if system: - messages.append({"role": "system", "content": system}) + if history_file: + try: + from pathlib import Path + history_path = Path(history_file) + if not history_path.exists(): + console.print(f"[red]Error: History file not found: {history_file}[/red]") + raise typer.Exit(2) + + with open(history_path) as f: + history_data = json.load(f) + # Support both direct array and object with 'messages' key + if isinstance(history_data, list): + messages = history_data + elif isinstance(history_data, dict) and "messages" in history_data: + messages = history_data["messages"] + else: + console.print("[red]Error: History file must contain array of messages or object with 'messages' key[/red]") + raise typer.Exit(2) + except json.JSONDecodeError as e: + console.print(f"[red]Error: Invalid JSON in history file: {e}[/red]") + raise typer.Exit(2) + except Exception as e: + console.print(f"[red]Error loading history file: {e}[/red]") + raise typer.Exit(2) + + # Build messages (add system prompt if not in history) + if system and not any(msg.get("role") == "system" for msg in messages): + messages.insert(0, {"role": "system", "content": system}) + + # Add current user prompt messages.append({"role": "user", "content": prompt}) # Build request body @@ -78,33 +107,49 @@ def send( ) as client: if body.get("stream"): # Streaming response - with client.stream( - "POST", - "/api/v1/chat/completions", - json=body, - ) as response: - if response.status_code >= 400: - handle_response(response) + try: + with client.stream( + "POST", + "/api/v1/chat/completions", + json=body, + ) as response: + if response.status_code >= 400: + handle_response(response) - full_content = "" - for line in response.iter_lines(): - if line.startswith("data: "): - data_str = line[6:] - if data_str.strip() == "[DONE]": - break - try: - data = json.loads(data_str) - delta = data.get("choices", [{}])[0].get("delta", {}) - content = delta.get("content", "") - if content: - print(content, end="", flush=True) - full_content += content - except json.JSONDecodeError: - continue - print() # Final newline + full_content = "" + try: + for line in response.iter_lines(): + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + delta = data.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + print(content, end="", flush=True) + full_content += content + except json.JSONDecodeError: + # Skip malformed JSON chunks + continue + except KeyboardInterrupt: + # Gracefully handle Ctrl-C during streaming + print() # Newline after partial output + console.print("\n[yellow]Stream interrupted by user[/yellow]") + if full_content and json_output: + console.print(json.dumps({"content": full_content, "interrupted": True}, indent=2)) + raise typer.Exit(0) - if json_output: - console.print(json.dumps({"content": full_content}, indent=2)) + print() # Final newline + + if json_output: + console.print(json.dumps({"content": full_content}, indent=2)) + + except (ConnectionError, TimeoutError) as e: + console.print(f"\n[red]Connection error during streaming: {e}[/red]") + console.print("[yellow]Try reducing timeout or checking network connection[/yellow]") + raise typer.Exit(4) else: # Non-streaming response response = client.post("/api/v1/chat/completions", json=body) @@ -116,6 +161,10 @@ def send( content = data.get("choices", [{}])[0].get("message", {}).get("content", "") console.print(content) + except KeyboardInterrupt: + # Handle Ctrl-C at top level + console.print("\n[yellow]Operation cancelled[/yellow]") + raise typer.Exit(0) except Exception as e: handle_request_error(e) diff --git a/openwebui_cli/config.py b/openwebui_cli/config.py index 50a4450..8030835 100644 --- a/openwebui_cli/config.py +++ b/openwebui_cli/config.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any import yaml -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from pydantic_settings import BaseSettings @@ -60,14 +60,12 @@ class Config(BaseModel): class Settings(BaseSettings): """Environment-based settings that override config file.""" + model_config = ConfigDict(env_prefix="", case_sensitive=False) + openwebui_uri: str | None = None openwebui_token: str | None = None openwebui_profile: str | None = None - class Config: - env_prefix = "" - case_sensitive = False - def load_config() -> Config: """Load configuration from file, with defaults for missing values.""" diff --git a/openwebui_cli/http.py b/openwebui_cli/http.py index 07a6581..5581734 100644 --- a/openwebui_cli/http.py +++ b/openwebui_cli/http.py @@ -120,18 +120,44 @@ def handle_response(response: httpx.Response) -> dict[str, Any]: CLIError: For other error responses """ if response.status_code == 401: - raise AuthError("Authentication required. Please run 'openwebui auth login'") + raise AuthError( + "Authentication required. Please run 'openwebui auth login' first.\n" + "If you recently logged in, your token may have expired." + ) elif response.status_code == 403: - raise AuthError("Permission denied. Check your access rights.") + raise AuthError( + "Permission denied. This operation requires higher privileges.\n" + "Possible causes:\n" + " - Your user role lacks required permissions\n" + " - The API key doesn't have sufficient access\n" + " - Try logging in again: openwebui auth login" + ) + elif response.status_code == 404: + try: + error_data = response.json() + message = error_data.get("detail", error_data.get("message", "Resource not found")) + except Exception: + message = "Resource not found" + raise ServerError( + f"Not found: {message}\n" + "Check that the resource ID, model name, or endpoint is correct." + ) elif response.status_code >= 500: - raise ServerError(f"Server error: {response.status_code} - {response.text}") + raise ServerError( + f"Server error ({response.status_code}): {response.text}\n" + "The OpenWebUI server encountered an error.\n" + "Try again in a moment, or check server logs if you're the administrator." + ) elif response.status_code >= 400: try: error_data = response.json() message = error_data.get("detail", error_data.get("message", response.text)) except Exception: message = response.text - raise ServerError(f"API error: {response.status_code} - {message}") + raise ServerError( + f"API error ({response.status_code}): {message}\n" + "Check your request parameters and try again." + ) try: return response.json() @@ -142,10 +168,25 @@ def handle_response(response: httpx.Response) -> dict[str, Any]: def handle_request_error(error: Exception) -> None: """Convert httpx errors to CLI errors.""" if isinstance(error, httpx.ConnectError): - raise NetworkError(f"Could not connect to server: {error}") + raise NetworkError( + f"Could not connect to server: {error}\n" + "Possible solutions:\n" + " - Check that OpenWebUI is running\n" + " - Verify the URI: openwebui config init\n" + " - Try: openwebui --uri http://localhost:8080 auth login" + ) elif isinstance(error, httpx.TimeoutException): - raise NetworkError(f"Request timed out: {error}") + raise NetworkError( + f"Request timed out: {error}\n" + "Possible solutions:\n" + " - Increase timeout: openwebui --timeout 60 ...\n" + " - Check your network connection\n" + " - The server might be overloaded" + ) elif isinstance(error, httpx.RequestError): - raise NetworkError(f"Request failed: {error}") + raise NetworkError( + f"Request failed: {error}\n" + "Check your network connection and server configuration." + ) else: raise error diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..e4c5e24 --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,301 @@ +"""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