openwebui-cli/openwebui_cli/http.py
Danny Stocker fbe6832d3e Improve CLI streaming, error handling, and add comprehensive tests
**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>
2025-11-30 20:28:21 +01:00

192 lines
5.8 KiB
Python

"""HTTP client wrapper for OpenWebUI API."""
from typing import Any, AsyncIterator
import httpx
import keyring
from .config import get_effective_config, load_config
from .errors import AuthError, NetworkError, ServerError
KEYRING_SERVICE = "openwebui-cli"
def get_token(profile: str, uri: str) -> str | None:
"""Retrieve token from system keyring."""
key = f"{profile}:{uri}"
return keyring.get_password(KEYRING_SERVICE, key)
def set_token(profile: str, uri: str, token: str) -> None:
"""Store token in system keyring."""
key = f"{profile}:{uri}"
keyring.set_password(KEYRING_SERVICE, key, token)
def delete_token(profile: str, uri: str) -> None:
"""Delete token from system keyring."""
key = f"{profile}:{uri}"
try:
keyring.delete_password(KEYRING_SERVICE, key)
except keyring.errors.PasswordDeleteError:
pass # Token doesn't exist, that's fine
def create_client(
profile: str | None = None,
uri: str | None = None,
token: str | None = None,
timeout: float | None = None,
) -> httpx.Client:
"""
Create an HTTP client configured for OpenWebUI API.
Args:
profile: Profile name to use
uri: Override server URI
token: Override token (otherwise uses keyring)
timeout: Request timeout in seconds
Returns:
Configured httpx.Client
"""
effective_uri, effective_profile = get_effective_config(profile, uri)
config = load_config()
# Get token from keyring if not provided
if token is None:
token = get_token(effective_profile, effective_uri)
# Build headers
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
# Use config timeout if not specified
if timeout is None:
timeout = config.defaults.timeout
return httpx.Client(
base_url=effective_uri,
headers=headers,
timeout=timeout,
)
def create_async_client(
profile: str | None = None,
uri: str | None = None,
token: str | None = None,
timeout: float | None = None,
) -> httpx.AsyncClient:
"""Create an async HTTP client configured for OpenWebUI API."""
effective_uri, effective_profile = get_effective_config(profile, uri)
config = load_config()
if token is None:
token = get_token(effective_profile, effective_uri)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
if timeout is None:
timeout = config.defaults.timeout
return httpx.AsyncClient(
base_url=effective_uri,
headers=headers,
timeout=timeout,
)
def handle_response(response: httpx.Response) -> dict[str, Any]:
"""
Handle API response, raising appropriate errors for failures.
Returns:
Parsed JSON response
Raises:
AuthError: For 401/403 responses
ServerError: For 5xx responses
CLIError: For other error responses
"""
if response.status_code == 401:
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. 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}\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}\n"
"Check your request parameters and try again."
)
try:
return response.json()
except Exception:
return {"text": response.text}
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}\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}\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}\n"
"Check your network connection and server configuration."
)
else:
raise error