Initial CLI scaffolding with v1.0 MVP structure
Complete Python CLI for OpenWebUI with: - Auth commands (login, logout, whoami, token, refresh) - Chat send with streaming support - RAG files/collections management - Models list/info - Admin stats (minimal v1.0) - Config management with profiles and keyring Stack: typer, httpx, rich, pydantic, keyring Based on RFC v1.2 with 22-step implementation checklist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
8530f74687
22 changed files with 2486 additions and 0 deletions
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
|
||||
# Build
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
config.yaml
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 InfraFabric Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
174
README.md
Normal file
174
README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# OpenWebUI CLI
|
||||
|
||||
Official command-line interface for [OpenWebUI](https://github.com/open-webui/open-webui).
|
||||
|
||||
> **Status:** Alpha - v0.1.0 MVP in development
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication** - Login, logout, token management with secure keyring storage
|
||||
- **Chat** - Send messages with streaming support, continue conversations
|
||||
- **RAG** - Upload files, manage collections, vector search
|
||||
- **Models** - List and inspect available models
|
||||
- **Admin** - Server stats and diagnostics (admin role required)
|
||||
- **Profiles** - Multiple server configurations
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install openwebui-cli
|
||||
```
|
||||
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dannystocker/openwebui-cli.git
|
||||
cd openwebui-cli
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Initialize configuration
|
||||
openwebui config init
|
||||
|
||||
# Login to your OpenWebUI instance
|
||||
openwebui auth login
|
||||
|
||||
# Chat with a model
|
||||
openwebui chat send -m llama3.2:latest -p "Hello, world!"
|
||||
|
||||
# Upload a file for RAG
|
||||
openwebui rag files upload ./document.pdf
|
||||
|
||||
# List available models
|
||||
openwebui models list
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Interactive login
|
||||
openwebui auth login
|
||||
|
||||
# Show current user
|
||||
openwebui auth whoami
|
||||
|
||||
# Logout
|
||||
openwebui auth logout
|
||||
```
|
||||
|
||||
### Chat
|
||||
|
||||
```bash
|
||||
# Simple chat (streaming by default)
|
||||
openwebui chat send -m llama3.2:latest -p "Explain quantum computing"
|
||||
|
||||
# Non-streaming mode
|
||||
openwebui chat send -m llama3.2:latest -p "Hello" --no-stream
|
||||
|
||||
# With RAG context
|
||||
openwebui chat send -m llama3.2:latest -p "Summarize this document" --file <FILE_ID>
|
||||
|
||||
# Continue a conversation
|
||||
openwebui chat send -m llama3.2:latest -p "Tell me more" --chat-id <CHAT_ID>
|
||||
```
|
||||
|
||||
### RAG (Retrieval-Augmented Generation)
|
||||
|
||||
```bash
|
||||
# Upload files
|
||||
openwebui rag files upload ./docs/*.pdf
|
||||
|
||||
# Create a collection
|
||||
openwebui rag collections create "Project Docs"
|
||||
|
||||
# Search within a collection
|
||||
openwebui rag search "authentication flow" --collection <COLL_ID>
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
```bash
|
||||
# List all models
|
||||
openwebui models list
|
||||
|
||||
# Get model details
|
||||
openwebui models info llama3.2:latest
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Initialize config
|
||||
openwebui config init
|
||||
|
||||
# Show current config
|
||||
openwebui config show
|
||||
|
||||
# Use a specific profile
|
||||
openwebui --profile production chat send -m gpt-4 -p "Hello"
|
||||
```
|
||||
|
||||
## Configuration File
|
||||
|
||||
Location: `~/.config/openwebui/config.yaml` (Linux/macOS) or `%APPDATA%\openwebui\config.yaml` (Windows)
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
default_profile: local
|
||||
|
||||
profiles:
|
||||
local:
|
||||
uri: http://localhost:8080
|
||||
production:
|
||||
uri: https://openwebui.example.com
|
||||
|
||||
defaults:
|
||||
model: llama3.2:latest
|
||||
format: text
|
||||
stream: true
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | General error |
|
||||
| 2 | Usage/argument error |
|
||||
| 3 | Authentication error |
|
||||
| 4 | Network error |
|
||||
| 5 | Server error (5xx) |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Type checking
|
||||
mypy openwebui_cli
|
||||
|
||||
# Linting
|
||||
ruff check openwebui_cli
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please read the [RFC proposal](docs/RFC.md) for design details.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [OpenWebUI](https://github.com/open-webui/open-webui) team
|
||||
- [mitchty/open-webui-cli](https://github.com/mitchty/open-webui-cli) for inspiration
|
||||
786
docs/RFC.md
Executable file
786
docs/RFC.md
Executable file
|
|
@ -0,0 +1,786 @@
|
|||
# OpenWebUI Official CLI - RFC Proposal
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Date:** November 30, 2025
|
||||
**Author:** InfraFabric Team
|
||||
**Target:** OpenWebUI Core Team Review
|
||||
**Status:** DRAFT - Reviewed & Implementation-Ready
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
We propose building an **official CLI for OpenWebUI** to complement the web interface. This CLI would enable developers, DevOps engineers, and power users to interact with OpenWebUI programmatically from the command line, enabling automation, scripting, and integration into CI/CD pipelines.
|
||||
|
||||
**Why Now:**
|
||||
- No official CLI exists (only `open-webui serve` to start server)
|
||||
- Community demand evidenced by third-party CLI projects
|
||||
- API surface is mature and well-documented
|
||||
- Enables new use cases: automation, headless operation, batch processing
|
||||
|
||||
---
|
||||
|
||||
## Resources Available for Development
|
||||
|
||||
### ChromaDB Knowledge Base (Proxmox Server)
|
||||
|
||||
We have ingested the **entire OpenWebUI ecosystem** into a semantic search database:
|
||||
|
||||
| Collection | Documents | Content |
|
||||
|------------|-----------|---------|
|
||||
| `openwebui_core` | 8,014 | Full source code (backend + frontend) |
|
||||
| `openwebui_docs` | 1,005 | Official documentation |
|
||||
| `openwebui_functions` | 315 | Functions repository |
|
||||
| `openwebui_pipelines` | 274 | Pipelines repository |
|
||||
| `openwebui_pain_points` | 219 | GitHub issues (bug reports, feature requests) |
|
||||
| `openwebui_careers` | 4 | Team/culture context |
|
||||
| **TOTAL** | **9,832 chunks** | Complete ecosystem knowledge |
|
||||
|
||||
**Query Example:**
|
||||
```python
|
||||
import chromadb
|
||||
c = chromadb.PersistentClient('/root/openwebui-knowledge/chromadb')
|
||||
results = c.get_collection('openwebui_core').query(
|
||||
query_texts=['API endpoint authentication'],
|
||||
n_results=10
|
||||
)
|
||||
```
|
||||
|
||||
This knowledge base allows us to:
|
||||
- Understand all API endpoints and their parameters
|
||||
- Identify user pain points from GitHub issues
|
||||
- Follow existing code patterns and conventions
|
||||
- Ensure compatibility with current architecture
|
||||
|
||||
### Discovered API Surface (from source analysis)
|
||||
|
||||
**Router Files Found:**
|
||||
- `auths.py` - Authentication, tokens, API keys
|
||||
- `models.py` - Model management
|
||||
- `ollama.py` - Ollama integration
|
||||
- `functions.py` - Custom functions
|
||||
- `pipelines.py` - Pipeline management
|
||||
- `evaluations.py` - Model evaluations
|
||||
- `scim.py` - SCIM provisioning
|
||||
|
||||
**Key Endpoints Identified:**
|
||||
```
|
||||
POST /api/v1/chat/completions - Main chat endpoint (OpenAI-compatible)
|
||||
POST /v1/chat/completions - Ollama passthrough
|
||||
POST /api/v1/tasks/*/completions - Various task endpoints
|
||||
GET /api/models - List models
|
||||
POST /api/pull - Pull model
|
||||
POST /rag/upload - Upload RAG file
|
||||
GET /api/v1/knowledge - List collections
|
||||
```
|
||||
|
||||
### Pain Points from GitHub Issues
|
||||
|
||||
| Issue | Problem | CLI Solution |
|
||||
|-------|---------|--------------|
|
||||
| #19420 | Unable to create API keys - 403 | `openwebui auth keys create` with proper scopes |
|
||||
| #19401 | Redis Sentinel auth bug | Config profiles with full connection strings |
|
||||
| #19403 | API keys refactor needed | First-class API key management in CLI |
|
||||
| #18948 | OAuth/OIDC complexity | `openwebui auth login --oauth` flow |
|
||||
| #19131 | OIDC client_id issues | Proper OAuth parameter handling |
|
||||
|
||||
### Existing Third-Party CLI Analysis
|
||||
|
||||
**mitchty/open-webui-cli** (Rust):
|
||||
- 40 commits, actively maintained (v0.1.2, Nov 2024)
|
||||
- ~1000 lines of Rust code
|
||||
- Features: chat, models, RAG collections, file upload
|
||||
|
||||
**What Works:**
|
||||
- Bearer token authentication
|
||||
- Collection/file-based RAG queries
|
||||
- Model list/pull operations
|
||||
- Clean command structure
|
||||
|
||||
**What's Missing:**
|
||||
- No streaming support
|
||||
- No OAuth flow
|
||||
- No config file (env vars only)
|
||||
- No admin operations
|
||||
- No function/pipeline management
|
||||
- Rust = different stack than OpenWebUI (Python)
|
||||
|
||||
---
|
||||
|
||||
## Proposed CLI Architecture
|
||||
|
||||
### Command Structure
|
||||
|
||||
```
|
||||
openwebui [global-options] <command> <subcommand> [options]
|
||||
|
||||
Global Options:
|
||||
--uri <URL> Server URL (default: $OPENWEBUI_URI or http://localhost:8080)
|
||||
--token <TOKEN> API token (default: $OPENWEBUI_TOKEN)
|
||||
--profile <NAME> Use named profile from config
|
||||
--format <FORMAT> Output format: text, json, yaml (default: text)
|
||||
--quiet Suppress non-essential output
|
||||
--verbose Enable debug logging
|
||||
--version Show version
|
||||
--help Show help
|
||||
```
|
||||
|
||||
### Complete Command Tree
|
||||
|
||||
```
|
||||
openwebui
|
||||
│
|
||||
├── auth # Authentication & Authorization
|
||||
│ ├── login # Interactive login (user/pass or OAuth)
|
||||
│ │ ├── --username, -u # Username for basic auth
|
||||
│ │ ├── --password, -p # Password (prompt if not provided)
|
||||
│ │ ├── --oauth # Use OAuth/OIDC flow
|
||||
│ │ └── --provider <NAME> # OAuth provider name
|
||||
│ ├── logout # Revoke current token
|
||||
│ ├── token # Show current token info
|
||||
│ │ ├── --refresh # Refresh token
|
||||
│ │ └── --export # Export token for scripts
|
||||
│ ├── keys # API key management
|
||||
│ │ ├── list # List API keys
|
||||
│ │ ├── create <NAME> # Create new API key
|
||||
│ │ │ └── --scopes <LIST> # Comma-separated scopes
|
||||
│ │ ├── revoke <KEY_ID> # Revoke API key
|
||||
│ │ └── info <KEY_ID> # Show key details
|
||||
│ └── whoami # Show current user info
|
||||
│
|
||||
├── chat # Chat Operations
|
||||
│ ├── send # Send a message
|
||||
│ │ ├── --model, -m <MODEL> # Model to use (required)
|
||||
│ │ ├── --prompt, -p <TEXT> # User prompt (or stdin)
|
||||
│ │ ├── --system, -s <TEXT> # System prompt
|
||||
│ │ ├── --file <ID> # RAG file ID(s) for context
|
||||
│ │ ├── --collection <ID> # RAG collection ID(s) for context
|
||||
│ │ ├── --stream # Stream response (default: true)
|
||||
│ │ ├── --no-stream # Wait for complete response
|
||||
│ │ ├── --chat-id <ID> # Continue existing conversation
|
||||
│ │ ├── --temperature <FLOAT> # Temperature (0.0-2.0)
|
||||
│ │ ├── --max-tokens <INT> # Max response tokens
|
||||
│ │ └── --json # Request JSON output
|
||||
│ ├── list # List conversations
|
||||
│ │ ├── --limit <N> # Number to show (default: 20)
|
||||
│ │ ├── --archived # Show archived chats
|
||||
│ │ └── --search <QUERY> # Search conversations
|
||||
│ ├── show <CHAT_ID> # Show conversation details
|
||||
│ ├── export <CHAT_ID> # Export conversation
|
||||
│ │ └── --format <FMT> # json, markdown, txt
|
||||
│ ├── archive <CHAT_ID> # Archive conversation
|
||||
│ ├── unarchive <CHAT_ID> # Unarchive conversation
|
||||
│ └── delete <CHAT_ID> # Delete conversation
|
||||
│
|
||||
├── models # Model Management
|
||||
│ ├── list # List available models
|
||||
│ │ ├── --provider <NAME> # Filter by provider (ollama, openai, etc.)
|
||||
│ │ └── --capabilities # Show model capabilities
|
||||
│ ├── info <MODEL_ID> # Show model details
|
||||
│ ├── pull <MODEL_NAME> # Pull/download model (Ollama)
|
||||
│ │ └── --progress # Show download progress
|
||||
│ ├── delete <MODEL_NAME> # Delete model
|
||||
│ └── copy <SRC> <DST> # Copy/alias model
|
||||
│
|
||||
├── rag # RAG (Retrieval-Augmented Generation)
|
||||
│ ├── files # File operations
|
||||
│ │ ├── list # List uploaded files
|
||||
│ │ ├── upload <PATH>... # Upload file(s)
|
||||
│ │ │ ├── --collection <ID> # Add to collection
|
||||
│ │ │ └── --tags <LIST> # Add tags
|
||||
│ │ ├── info <FILE_ID> # File details
|
||||
│ │ ├── download <FILE_ID> # Download file
|
||||
│ │ └── delete <FILE_ID> # Delete file
|
||||
│ ├── collections # Collection operations
|
||||
│ │ ├── list # List collections
|
||||
│ │ ├── create <NAME> # Create collection
|
||||
│ │ │ └── --description <TEXT>
|
||||
│ │ ├── info <COLL_ID> # Collection details
|
||||
│ │ ├── add <COLL_ID> <FILE_ID>... # Add files to collection
|
||||
│ │ ├── remove <COLL_ID> <FILE_ID> # Remove file from collection
|
||||
│ │ └── delete <COLL_ID> # Delete collection
|
||||
│ └── query <QUERY> # Direct vector search (no LLM)
|
||||
│ ├── --collection <ID> # Search in collection
|
||||
│ ├── --file <ID> # Search in file
|
||||
│ ├── --top-k <N> # Number of results (default: 5)
|
||||
│ └── --threshold <FLOAT> # Similarity threshold
|
||||
│
|
||||
├── functions # Custom Functions
|
||||
│ ├── list # List installed functions
|
||||
│ │ └── --enabled-only # Show only enabled
|
||||
│ ├── info <FUNC_ID> # Function details
|
||||
│ ├── install <URL|PATH> # Install function
|
||||
│ ├── enable <FUNC_ID> # Enable function
|
||||
│ ├── disable <FUNC_ID> # Disable function
|
||||
│ ├── update <FUNC_ID> # Update function
|
||||
│ └── delete <FUNC_ID> # Delete function
|
||||
│
|
||||
├── pipelines # Pipeline Management
|
||||
│ ├── list # List pipelines
|
||||
│ ├── info <PIPE_ID> # Pipeline details
|
||||
│ ├── create <NAME> # Create pipeline
|
||||
│ │ └── --config <FILE> # Pipeline config file
|
||||
│ ├── update <PIPE_ID> # Update pipeline
|
||||
│ └── delete <PIPE_ID> # Delete pipeline
|
||||
│
|
||||
├── admin # Admin Operations (requires admin role)
|
||||
│ ├── users # User management
|
||||
│ │ ├── list # List users
|
||||
│ │ ├── create <EMAIL> # Create user
|
||||
│ │ ├── info <USER_ID> # User details
|
||||
│ │ ├── update <USER_ID> # Update user
|
||||
│ │ ├── delete <USER_ID> # Delete user
|
||||
│ │ └── set-role <USER_ID> <ROLE>
|
||||
│ ├── config # Server configuration
|
||||
│ │ ├── get [KEY] # Get config value(s)
|
||||
│ │ └── set <KEY> <VALUE> # Set config value
|
||||
│ └── stats # Usage statistics
|
||||
│ ├── --period <PERIOD> # day, week, month
|
||||
│ └── --export # Export as CSV
|
||||
│
|
||||
└── config # CLI Configuration
|
||||
├── init # Initialize config file
|
||||
├── show # Show current config
|
||||
├── set <KEY> <VALUE> # Set config value
|
||||
├── get <KEY> # Get config value
|
||||
└── profiles # Profile management
|
||||
├── list # List profiles
|
||||
├── create <NAME> # Create profile
|
||||
├── use <NAME> # Set default profile
|
||||
└── delete <NAME> # Delete profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Component | Choice | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Language | Python 3.11+ | Matches OpenWebUI backend |
|
||||
| CLI Framework | `typer` | Modern, type-hints, auto-completion |
|
||||
| HTTP Client | `httpx` | Async support, streaming |
|
||||
| Output Formatting | `rich` | Beautiful terminal output |
|
||||
| Config | `pydantic` + YAML | Type-safe configuration |
|
||||
| Auth | `keyring` | Secure token storage |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
openwebui-cli/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
├── src/
|
||||
│ └── openwebui_cli/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # Entry point
|
||||
│ ├── cli.py # Main CLI app
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── client.py # HTTP client wrapper
|
||||
│ ├── auth.py # Authentication logic
|
||||
│ ├── commands/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth.py # auth subcommands
|
||||
│ │ ├── chat.py # chat subcommands
|
||||
│ │ ├── models.py # models subcommands
|
||||
│ │ ├── rag.py # rag subcommands
|
||||
│ │ ├── functions.py # functions subcommands
|
||||
│ │ ├── pipelines.py # pipelines subcommands
|
||||
│ │ ├── admin.py # admin subcommands
|
||||
│ │ └── config_cmd.py # config subcommands
|
||||
│ ├── formatters/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── text.py # Plain text output
|
||||
│ │ ├── json.py # JSON output
|
||||
│ │ └── table.py # Table output
|
||||
│ └── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── streaming.py # SSE handling
|
||||
│ └── progress.py # Progress bars
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_auth.py
|
||||
├── test_chat.py
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
**Location:** `~/.openwebui/config.yaml`
|
||||
|
||||
```yaml
|
||||
# OpenWebUI CLI Configuration
|
||||
version: 1
|
||||
|
||||
# Default profile
|
||||
default_profile: local
|
||||
|
||||
# Profiles for different servers
|
||||
profiles:
|
||||
local:
|
||||
uri: http://localhost:8080
|
||||
# Token stored in system keyring
|
||||
|
||||
production:
|
||||
uri: https://openwebui.example.com
|
||||
# Token stored in system keyring
|
||||
|
||||
# CLI defaults
|
||||
defaults:
|
||||
model: llama3.2:latest
|
||||
format: text
|
||||
stream: true
|
||||
|
||||
# Output preferences
|
||||
output:
|
||||
colors: true
|
||||
progress_bars: true
|
||||
timestamps: false
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
1. First-time setup:
|
||||
$ openwebui auth login
|
||||
> Enter username: user@example.com
|
||||
> Enter password: ********
|
||||
> Token saved to keyring
|
||||
|
||||
2. OAuth flow:
|
||||
$ openwebui auth login --oauth --provider google
|
||||
> Opening browser for authentication...
|
||||
> Token saved to keyring
|
||||
|
||||
3. API key (for scripts):
|
||||
$ openwebui auth keys create my-script --scopes chat,models
|
||||
> API Key: sk-xxxxxxxxxxxx
|
||||
> (Use with OPENWEBUI_TOKEN env var)
|
||||
```
|
||||
|
||||
### Streaming Implementation
|
||||
|
||||
```python
|
||||
async def stream_chat(client, model, prompt, **kwargs):
|
||||
"""Stream chat completion with real-time output."""
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"/api/v1/chat/completions",
|
||||
json={"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True},
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data = json.loads(line[6:])
|
||||
if content := data.get("choices", [{}])[0].get("delta", {}).get("content"):
|
||||
print(content, end="", flush=True)
|
||||
print() # Newline at end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v1.0 MVP Scope (Ship This First)
|
||||
|
||||
These are the commands included in the **first PR / first release**:
|
||||
|
||||
### Top-level & Shared
|
||||
|
||||
**Global flags:**
|
||||
- `--profile` - Use named profile from config
|
||||
- `--uri` - Server URL
|
||||
- `--format` - Output format (text, json, yaml)
|
||||
- `--quiet` - Suppress non-essential output
|
||||
- `--verbose/--debug` - Enable debug logging
|
||||
- `--timeout` - Request timeout
|
||||
|
||||
**Config (v1.0):**
|
||||
- `config init` - Initialize config file (interactive)
|
||||
- `config show` - Show current config (redact secrets)
|
||||
- `config set <KEY> <VALUE>` - Set config value (optional)
|
||||
|
||||
### Auth (v1.0)
|
||||
|
||||
- `auth login` - Interactive login (username/password)
|
||||
- `auth logout` - Revoke current token
|
||||
- `auth whoami` - Show current user info
|
||||
- `auth token` - Show token info (type, expiry, not raw)
|
||||
- `auth refresh` - Refresh token if available
|
||||
- Token storage via `keyring` (no plaintext tokens in config)
|
||||
|
||||
*API keys (`auth keys`) deferred to v1.1 for smaller first cut.*
|
||||
|
||||
### Chat (v1.0)
|
||||
|
||||
- `chat send`
|
||||
- `--model | -m` - Model to use (required)
|
||||
- `--prompt | -p` - User prompt (or stdin)
|
||||
- `--chat-id` - Continue existing conversation
|
||||
- `--history-file` - Load history from file
|
||||
- `--no-stream` - Wait for complete response
|
||||
- `--format` + `--json` - Output format options
|
||||
- **Streaming ON by default** (from config)
|
||||
|
||||
*Chat history commands (`list/show/export/archive/delete`) deferred to v1.1.*
|
||||
|
||||
### RAG (v1.0 - Minimal)
|
||||
|
||||
- `rag files list` - List uploaded files
|
||||
- `rag files upload <PATH>... [--collection <ID>]` - Upload file(s)
|
||||
- `rag files delete <FILE_ID>` - Delete file
|
||||
- `rag collections list` - List collections
|
||||
- `rag collections create <NAME>` - Create collection
|
||||
- `rag collections delete <COLL_ID>` - Delete collection
|
||||
- `rag search <QUERY> --collection <ID> --top-k <N> --format json` - Vector search
|
||||
|
||||
### Models (v1.0)
|
||||
|
||||
- `models list` - List available models
|
||||
- `models info <MODEL_ID>` - Show model details
|
||||
|
||||
*Model operations (`pull/delete/copy`) deferred to v1.1 depending on API maturity.*
|
||||
|
||||
### Admin (v1.0 - Minimal)
|
||||
|
||||
- `admin stats --format <text|json>` - Usage statistics
|
||||
- RBAC enforced via server token
|
||||
- Exit code `3` on permission errors
|
||||
|
||||
*Admin user/config management deferred to v1.1.*
|
||||
|
||||
---
|
||||
|
||||
## v1.1+ Enhancements (Next Iterations)
|
||||
|
||||
These are ideal follow-ups once v1.0 is stable:
|
||||
|
||||
### Auth & Keys (v1.1)
|
||||
|
||||
- `auth keys list` - List API keys
|
||||
- `auth keys create [--name --scope ...]` - Create API key
|
||||
- `auth keys revoke <KEY_ID>` - Revoke API key
|
||||
- More structured scope model & docs (e.g. `read:chat`, `write:rag`, `admin:*`)
|
||||
|
||||
### Chat Quality-of-Life (v1.1)
|
||||
|
||||
- `chat list` - List conversations
|
||||
- `chat show <CHAT_ID>` - Show conversation details
|
||||
- `chat export <CHAT_ID> [--format markdown|json]` - Export conversation
|
||||
- `chat archive/delete <CHAT_ID>` - Archive or delete
|
||||
- `--system-prompt` or `--meta` support once server-side API supports rich metadata
|
||||
|
||||
### Models (v1.1)
|
||||
|
||||
- `models pull` - Pull/download model
|
||||
- `models delete` - Delete model
|
||||
- `models copy` - Copy/alias model
|
||||
- Clear handling of local vs remote model registries
|
||||
|
||||
### RAG UX (v1.1+)
|
||||
|
||||
- `rag collections add <COLL_ID> <FILE_ID>...` - Add files to collection
|
||||
- `rag collections ingest <COLL_ID> <PATH>...` - Upload + add in one go (v1.2+)
|
||||
|
||||
### Functions & Pipelines (v1.1+)
|
||||
|
||||
- `functions list/install/enable/disable/delete`
|
||||
- `pipelines list/create/delete/run`
|
||||
- Official JSON schema for pipeline configs & function manifests
|
||||
|
||||
### Developer Ergonomics (v1.1+)
|
||||
|
||||
- Shell completions: `openwebui --install-completion` / `--show-completion`
|
||||
- Better error pretty-printing with `rich` (esp. for validation errors)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist (22 Concrete Steps)
|
||||
|
||||
Use this as a PR checklist:
|
||||
|
||||
### A. Skeleton & CLI Wiring
|
||||
|
||||
1. **Create package layout** in monorepo:
|
||||
```text
|
||||
open-webui/
|
||||
cli/
|
||||
pyproject.toml
|
||||
openwebui_cli/
|
||||
__init__.py
|
||||
main.py
|
||||
config.py
|
||||
auth.py
|
||||
chat.py
|
||||
rag.py
|
||||
models.py
|
||||
admin.py
|
||||
http.py
|
||||
errors.py
|
||||
```
|
||||
|
||||
2. **Wire Typer app** in `main.py`:
|
||||
- Main `app = typer.Typer()`
|
||||
- Sub-apps: `auth_app`, `chat_app`, `rag_app`, `models_app`, `admin_app`, `config_app`
|
||||
- Global options (profile, uri, format, quiet, verbose, timeout)
|
||||
|
||||
3. **Implement central HTTP client helper** in `http.py`:
|
||||
- Builds `httpx.Client` from resolved URI, timeout, auth headers
|
||||
- Token from keyring
|
||||
- Standard error translation → `CLIError` subclasses
|
||||
|
||||
### B. Config & Profiles
|
||||
|
||||
4. **Implement config path resolution:**
|
||||
- Unix: XDG → `~/.config/openwebui/config.yaml`
|
||||
- Windows: `%APPDATA%\openwebui\config.yaml`
|
||||
|
||||
5. **Implement config commands:**
|
||||
- `config init` (interactive: ask URI, default model, default format)
|
||||
- `config show` (redact secrets, e.g. token placeholders)
|
||||
|
||||
6. **Implement config loading & precedence:**
|
||||
- Load file → apply profile → apply env → override with CLI flags
|
||||
|
||||
### C. Auth Flow
|
||||
|
||||
7. **Implement token storage using `keyring`:**
|
||||
- Key name: `openwebui:{profile}:{uri}`
|
||||
|
||||
8. **`auth login`:**
|
||||
- Prompt for username/password
|
||||
- Exchange for token using server's auth endpoint
|
||||
- Future: add browser-based OAuth once endpoints are known
|
||||
|
||||
9. **`auth logout`:**
|
||||
- Delete token from keyring
|
||||
|
||||
10. **`auth whoami`:**
|
||||
- Call `/me`/`/users/me` style endpoint
|
||||
- Print name, email, roles
|
||||
|
||||
11. **`auth token`:**
|
||||
- Show minimal info: token type, expiry
|
||||
- Not the full raw token (or show only if `--debug`)
|
||||
|
||||
12. **`auth refresh`:**
|
||||
- Call refresh endpoint if available
|
||||
- Update token in keyring
|
||||
- Exit code `3` if refresh fails due to auth
|
||||
|
||||
### D. Chat Send + Streaming
|
||||
|
||||
13. **Implement `chat send`:**
|
||||
- Resolve model, prompt, chat ID, history file
|
||||
- If streaming:
|
||||
- Use HTTP streaming endpoint
|
||||
- Print tokens as they arrive
|
||||
- Handle Ctrl-C gracefully
|
||||
- If `--no-stream`:
|
||||
- Wait for full response
|
||||
- Respect `--format`/`--json`:
|
||||
- `text`: print body content only
|
||||
- `json`: print full JSON once
|
||||
|
||||
14. **Ensure exit codes follow the table:**
|
||||
- Usage errors → 2
|
||||
- Auth failures → 3
|
||||
- Network errors → 4
|
||||
- Server error (e.g., 500) → 5
|
||||
|
||||
### E. RAG Minimal API
|
||||
|
||||
15. **Implement `rag files list/upload/delete`:**
|
||||
- Upload: handle multiple paths; show IDs
|
||||
- `--collection` optional; if set, also attach uploaded files
|
||||
|
||||
16. **Implement `rag collections list/create/delete`**
|
||||
|
||||
17. **Implement `rag search`:**
|
||||
- Non-streaming only (v1.0)
|
||||
- Default `--format json`; text mode optionally summarized
|
||||
- Return exit code `0` even for empty results; use `1` only when *error*
|
||||
|
||||
### F. Models & Admin
|
||||
|
||||
18. **Models:**
|
||||
- Implement `models list` and `models info` wired to existing endpoints
|
||||
|
||||
19. **Admin:**
|
||||
- Implement `admin stats` as a thin wrapper
|
||||
- Check permission errors → exit code `3` with clear message:
|
||||
> "Admin command requires admin privileges; your current user is 'X' with roles: [user]."
|
||||
|
||||
### G. Tests & Docs
|
||||
|
||||
20. **Add unit tests:**
|
||||
- Config precedence
|
||||
- Exit code mapping
|
||||
- Basic command parsing (Typer's test runner)
|
||||
|
||||
21. **Add smoke test script for maintainers:**
|
||||
- `openwebui --help`
|
||||
- `openwebui chat send --help`
|
||||
- `openwebui rag search --help`
|
||||
|
||||
22. **Add minimal README for the CLI:**
|
||||
- Install (`pip install openwebui-cli` / `open-webui[cli]`)
|
||||
- Basic `auth login`, `chat send`, `rag search` examples
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Chat
|
||||
```bash
|
||||
# Simple chat
|
||||
$ openwebui chat send -m llama3.2:latest -p "What is the capital of France?"
|
||||
Paris is the capital of France.
|
||||
|
||||
# With system prompt
|
||||
$ openwebui chat send -m llama3.2:latest \
|
||||
-s "You are a helpful coding assistant" \
|
||||
-p "Write a Python function to calculate fibonacci"
|
||||
|
||||
# From stdin (pipe)
|
||||
$ echo "Explain quantum computing" | openwebui chat send -m llama3.2:latest
|
||||
|
||||
# Continue conversation
|
||||
$ openwebui chat send -m llama3.2:latest --chat-id abc123 -p "Tell me more"
|
||||
```
|
||||
|
||||
### RAG Workflow
|
||||
```bash
|
||||
# Upload documents
|
||||
$ openwebui rag files upload ./docs/*.pdf
|
||||
Uploaded: doc1.pdf (id: file-abc123)
|
||||
Uploaded: doc2.pdf (id: file-def456)
|
||||
|
||||
# Create collection
|
||||
$ openwebui rag collections create "Project Docs" --description "Project documentation"
|
||||
Created collection: coll-xyz789
|
||||
|
||||
# Add files to collection
|
||||
$ openwebui rag collections add coll-xyz789 file-abc123 file-def456
|
||||
Added 2 files to collection
|
||||
|
||||
# Chat with RAG context
|
||||
$ openwebui chat send -m llama3.2:latest \
|
||||
--collection coll-xyz789 \
|
||||
-p "Summarize the main points from these documents"
|
||||
```
|
||||
|
||||
### Scripting & Automation
|
||||
```bash
|
||||
# Export chat history
|
||||
$ openwebui chat export abc123 --format json > chat_backup.json
|
||||
|
||||
# Batch model pull
|
||||
$ cat models.txt | xargs -I {} openwebui models pull {}
|
||||
|
||||
# List all collections as JSON (for scripting)
|
||||
$ openwebui rag collections list --format json | jq '.[] | .id'
|
||||
|
||||
# Health check script
|
||||
$ openwebui admin stats --format json | jq '.status'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribution Strategy
|
||||
|
||||
### For OpenWebUI Core Team
|
||||
|
||||
1. **Proposal Review:** This document for initial feedback
|
||||
2. **RFC Discussion:** Open GitHub Discussion for community input
|
||||
3. **Implementation:** In `open-webui/cli` or separate repo initially
|
||||
4. **Integration:** Merge into main repo as `openwebui-cli` package
|
||||
5. **Release:** Alongside next minor version
|
||||
|
||||
### Packaging
|
||||
|
||||
```bash
|
||||
# Install from PyPI (target)
|
||||
pip install openwebui-cli
|
||||
|
||||
# Or with OpenWebUI
|
||||
pip install open-webui[cli]
|
||||
|
||||
# Command available as
|
||||
openwebui <command>
|
||||
# or
|
||||
open-webui-cli <command>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for Review
|
||||
|
||||
1. **Command naming:** `openwebui` vs `owui` vs `webui`?
|
||||
2. **Scope:** Include admin commands in v1.0 or defer?
|
||||
3. **Repository:** Separate repo or monorepo with open-webui?
|
||||
4. **Streaming default:** On by default or opt-in?
|
||||
5. **Config location:** `~/.openwebui/` vs `~/.config/openwebui/`?
|
||||
|
||||
---
|
||||
|
||||
## Appendix: API Endpoint Reference
|
||||
|
||||
Based on ChromaDB analysis of OpenWebUI source code:
|
||||
|
||||
### Authentication
|
||||
```
|
||||
POST /api/v1/auths/signin - Sign in
|
||||
POST /api/v1/auths/signup - Sign up
|
||||
POST /api/v1/auths/signout - Sign out
|
||||
GET /api/v1/auths/ - Current user info
|
||||
POST /api/v1/auths/api_key - Create API key
|
||||
DELETE /api/v1/auths/api_key - Delete API key
|
||||
```
|
||||
|
||||
### Chat
|
||||
```
|
||||
POST /api/v1/chat/completions - Chat completion (OpenAI-compatible)
|
||||
GET /api/v1/chats/ - List chats
|
||||
GET /api/v1/chats/{id} - Get chat
|
||||
DELETE /api/v1/chats/{id} - Delete chat
|
||||
POST /api/v1/chats/{id}/archive - Archive chat
|
||||
```
|
||||
|
||||
### Models
|
||||
```
|
||||
GET /api/models - List models
|
||||
GET /api/models/{id} - Model info
|
||||
POST /api/pull - Pull model (Ollama)
|
||||
DELETE /api/models/{id} - Delete model
|
||||
```
|
||||
|
||||
### RAG
|
||||
```
|
||||
POST /api/v1/files/ - Upload file
|
||||
GET /api/v1/files/ - List files
|
||||
DELETE /api/v1/files/{id} - Delete file
|
||||
GET /api/v1/knowledge/ - List collections
|
||||
POST /api/v1/knowledge/ - Create collection
|
||||
DELETE /api/v1/knowledge/{id} - Delete collection
|
||||
```
|
||||
|
||||
### Functions & Pipelines
|
||||
```
|
||||
GET /api/v1/functions/ - List functions
|
||||
POST /api/v1/functions/ - Create function
|
||||
GET /api/v1/pipelines/ - List pipelines
|
||||
POST /api/v1/pipelines/ - Create pipeline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Document Control
|
||||
|
||||
| Version | Date | Author | Changes |
|
||||
|---------|------|--------|---------|
|
||||
| 1.0 | 2025-11-30 | InfraFabric | Initial RFC |
|
||||
| 1.2 | 2025-11-30 | InfraFabric + Grok Review | Added v1.0 MVP scope, v1.1+ roadmap, 22-step implementation checklist, exit code table |
|
||||
|
||||
---
|
||||
|
||||
*This proposal leverages 9,832 chunks of OpenWebUI source code, documentation, and issue analysis from our ChromaDB knowledge base to ensure comprehensive API coverage and alignment with user needs.*
|
||||
4
openwebui_cli/__init__.py
Normal file
4
openwebui_cli/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""OpenWebUI CLI - Official command-line interface for OpenWebUI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "InfraFabric Team"
|
||||
1
openwebui_cli/commands/__init__.py
Normal file
1
openwebui_cli/commands/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""CLI command modules."""
|
||||
78
openwebui_cli/commands/admin.py
Normal file
78
openwebui_cli/commands/admin.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Admin commands (requires admin role)."""
|
||||
|
||||
import json
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ..http import create_client, handle_response, handle_request_error
|
||||
from ..errors import AuthError
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def stats(
|
||||
ctx: typer.Context,
|
||||
period: str = typer.Option("day", "--period", "-p", help="Period: day, week, month"),
|
||||
) -> None:
|
||||
"""Show usage statistics."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
# Try to get stats from various endpoints
|
||||
try:
|
||||
response = client.get("/api/v1/admin/stats")
|
||||
data = handle_response(response)
|
||||
except Exception:
|
||||
# Fallback to basic info
|
||||
response = client.get("/api/v1/auths/")
|
||||
user_data = handle_response(response)
|
||||
|
||||
if user_data.get("role") != "admin":
|
||||
raise AuthError(
|
||||
f"Admin command requires admin privileges; "
|
||||
f"your current user is '{user_data.get('name')}' with role: [{user_data.get('role')}]"
|
||||
)
|
||||
|
||||
# Build basic stats
|
||||
data = {
|
||||
"user": user_data.get("name"),
|
||||
"role": user_data.get("role"),
|
||||
"status": "connected",
|
||||
}
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(data, indent=2))
|
||||
else:
|
||||
table = Table(title="Server Statistics")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
for key, value in data.items():
|
||||
table.add_row(str(key), str(value))
|
||||
|
||||
console.print(table)
|
||||
|
||||
except AuthError:
|
||||
raise
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command()
|
||||
def users(ctx: typer.Context) -> None:
|
||||
"""List users (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Admin users will be available in v1.1[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def config(ctx: typer.Context) -> None:
|
||||
"""Server configuration (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Admin config will be available in v1.1[/yellow]")
|
||||
123
openwebui_cli/commands/auth.py
Normal file
123
openwebui_cli/commands/auth.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Authentication commands."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from ..config import get_effective_config
|
||||
from ..http import create_client, set_token, delete_token, get_token, handle_response, handle_request_error
|
||||
from ..errors import AuthError
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def login(
|
||||
ctx: typer.Context,
|
||||
username: str | None = typer.Option(None, "--username", "-u", help="Username or email"),
|
||||
password: str | None = typer.Option(None, "--password", "-p", help="Password (will prompt if not provided)"),
|
||||
) -> None:
|
||||
"""Login to OpenWebUI instance."""
|
||||
obj = ctx.obj or {}
|
||||
uri, profile = get_effective_config(obj.get("profile"), obj.get("uri"))
|
||||
|
||||
# Prompt for credentials if not provided
|
||||
if username is None:
|
||||
username = Prompt.ask("Username or email")
|
||||
if password is None:
|
||||
password = Prompt.ask("Password", password=True)
|
||||
|
||||
try:
|
||||
with create_client(profile=profile, uri=uri) as client:
|
||||
response = client.post(
|
||||
"/api/v1/auths/signin",
|
||||
json={"email": username, "password": password},
|
||||
)
|
||||
data = handle_response(response)
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
raise AuthError("No token received from server")
|
||||
|
||||
# Store token in keyring
|
||||
set_token(profile, uri, token)
|
||||
|
||||
user_name = data.get("name", username)
|
||||
console.print(f"[green]Successfully logged in as {user_name}[/green]")
|
||||
console.print(f"Token saved to system keyring for profile: {profile}")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command()
|
||||
def logout(ctx: typer.Context) -> None:
|
||||
"""Logout and remove stored token."""
|
||||
obj = ctx.obj or {}
|
||||
uri, profile = get_effective_config(obj.get("profile"), obj.get("uri"))
|
||||
|
||||
delete_token(profile, uri)
|
||||
console.print(f"[green]Logged out from profile: {profile}[/green]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def whoami(ctx: typer.Context) -> None:
|
||||
"""Show current user information."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(profile=obj.get("profile"), uri=obj.get("uri")) as client:
|
||||
response = client.get("/api/v1/auths/")
|
||||
data = handle_response(response)
|
||||
|
||||
console.print(f"[bold]User:[/bold] {data.get('name', 'Unknown')}")
|
||||
console.print(f"[bold]Email:[/bold] {data.get('email', 'Unknown')}")
|
||||
console.print(f"[bold]Role:[/bold] {data.get('role', 'Unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command()
|
||||
def token(
|
||||
ctx: typer.Context,
|
||||
show: bool = typer.Option(False, "--show", help="Show full token (careful!)"),
|
||||
) -> None:
|
||||
"""Show token information."""
|
||||
obj = ctx.obj or {}
|
||||
uri, profile = get_effective_config(obj.get("profile"), obj.get("uri"))
|
||||
|
||||
stored_token = get_token(profile, uri)
|
||||
if stored_token:
|
||||
if show:
|
||||
console.print(f"[bold]Token:[/bold] {stored_token}")
|
||||
else:
|
||||
masked = stored_token[:8] + "..." + stored_token[-4:] if len(stored_token) > 12 else "***"
|
||||
console.print(f"[bold]Token:[/bold] {masked}")
|
||||
console.print(f"[bold]Profile:[/bold] {profile}")
|
||||
console.print(f"[bold]URI:[/bold] {uri}")
|
||||
else:
|
||||
console.print("[yellow]No token found. Run 'openwebui auth login' first.[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def refresh(ctx: typer.Context) -> None:
|
||||
"""Refresh the authentication token."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(profile=obj.get("profile"), uri=obj.get("uri")) as client:
|
||||
response = client.post("/api/v1/auths/refresh")
|
||||
data = handle_response(response)
|
||||
|
||||
uri, profile = get_effective_config(obj.get("profile"), obj.get("uri"))
|
||||
token = data.get("token")
|
||||
if token:
|
||||
set_token(profile, uri, token)
|
||||
console.print("[green]Token refreshed successfully[/green]")
|
||||
else:
|
||||
console.print("[yellow]No new token received[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
149
openwebui_cli/commands/chat.py
Normal file
149
openwebui_cli/commands/chat.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Chat commands."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
|
||||
from ..config import load_config
|
||||
from ..http import create_client, handle_response, handle_request_error
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def send(
|
||||
ctx: typer.Context,
|
||||
model: str = typer.Option(..., "--model", "-m", help="Model to use"),
|
||||
prompt: str | None = typer.Option(None, "--prompt", "-p", help="User prompt (or use stdin)"),
|
||||
system: str | None = typer.Option(None, "--system", "-s", help="System prompt"),
|
||||
chat_id: str | None = typer.Option(None, "--chat-id", help="Continue existing conversation"),
|
||||
file: list[str] | None = typer.Option(None, "--file", help="RAG file ID(s) for context"),
|
||||
collection: list[str] | None = typer.Option(None, "--collection", help="RAG collection ID(s) for context"),
|
||||
no_stream: bool = typer.Option(False, "--no-stream", help="Wait for complete response"),
|
||||
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"),
|
||||
) -> None:
|
||||
"""Send a chat message."""
|
||||
obj = ctx.obj or {}
|
||||
config = load_config()
|
||||
|
||||
# Get prompt from stdin if not provided
|
||||
if prompt is None:
|
||||
if not sys.stdin.isatty():
|
||||
prompt = sys.stdin.read().strip()
|
||||
else:
|
||||
console.print("[red]Error: Prompt required. Use -p or pipe input.[/red]")
|
||||
raise typer.Exit(2)
|
||||
|
||||
# Build messages
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Build request body
|
||||
body: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": not no_stream and config.defaults.stream,
|
||||
}
|
||||
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
body["max_tokens"] = max_tokens
|
||||
|
||||
# Add RAG context if specified
|
||||
files_context = []
|
||||
if file:
|
||||
for f in file:
|
||||
files_context.append({"type": "file", "id": f})
|
||||
if collection:
|
||||
for c in collection:
|
||||
files_context.append({"type": "collection", "id": c})
|
||||
if files_context:
|
||||
body["files"] = files_context
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
timeout=obj.get("timeout"),
|
||||
) 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)
|
||||
|
||||
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
|
||||
|
||||
if json_output:
|
||||
console.print(json.dumps({"content": full_content}, indent=2))
|
||||
else:
|
||||
# Non-streaming response
|
||||
response = client.post("/api/v1/chat/completions", json=body)
|
||||
data = handle_response(response)
|
||||
|
||||
if json_output or obj.get("format") == "json":
|
||||
console.print(json.dumps(data, indent=2))
|
||||
else:
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
console.print(content)
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_chats(
|
||||
ctx: typer.Context,
|
||||
limit: int = typer.Option(20, "--limit", "-n", help="Number of chats to show"),
|
||||
archived: bool = typer.Option(False, "--archived", help="Show archived chats"),
|
||||
) -> None:
|
||||
"""List conversations (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Chat list will be available in v1.1[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(
|
||||
ctx: typer.Context,
|
||||
chat_id: str = typer.Argument(..., help="Chat ID to show"),
|
||||
) -> None:
|
||||
"""Show conversation details (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Chat show will be available in v1.1[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def export(
|
||||
ctx: typer.Context,
|
||||
chat_id: str = typer.Argument(..., help="Chat ID to export"),
|
||||
format: str = typer.Option("json", "--format", "-f", help="Export format: json, markdown"),
|
||||
) -> None:
|
||||
"""Export conversation (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Chat export will be available in v1.1[/yellow]")
|
||||
173
openwebui_cli/commands/config_cmd.py
Normal file
173
openwebui_cli/commands/config_cmd.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""CLI configuration commands."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
|
||||
from ..config import (
|
||||
Config,
|
||||
ProfileConfig,
|
||||
get_config_path,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
ctx: typer.Context,
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
|
||||
) -> None:
|
||||
"""Initialize configuration file interactively."""
|
||||
config_path = get_config_path()
|
||||
|
||||
if config_path.exists() and not force:
|
||||
console.print(f"[yellow]Config already exists at: {config_path}[/yellow]")
|
||||
console.print("Use --force to overwrite")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("[bold]OpenWebUI CLI Configuration Setup[/bold]\n")
|
||||
|
||||
# Get server URI
|
||||
uri = Prompt.ask(
|
||||
"Server URI",
|
||||
default="http://localhost:8080",
|
||||
)
|
||||
|
||||
# Get default model
|
||||
default_model = Prompt.ask(
|
||||
"Default model (optional)",
|
||||
default="",
|
||||
)
|
||||
|
||||
# Get output format
|
||||
default_format = Prompt.ask(
|
||||
"Default output format",
|
||||
choices=["text", "json", "yaml"],
|
||||
default="text",
|
||||
)
|
||||
|
||||
# Build config
|
||||
config = Config(
|
||||
default_profile="default",
|
||||
profiles={
|
||||
"default": ProfileConfig(uri=uri),
|
||||
},
|
||||
)
|
||||
|
||||
if default_model:
|
||||
config.defaults.model = default_model
|
||||
config.defaults.format = default_format
|
||||
|
||||
# Save config
|
||||
save_config(config)
|
||||
console.print(f"\n[green]Configuration saved to: {config_path}[/green]")
|
||||
console.print("\nNext steps:")
|
||||
console.print(" 1. Run 'openwebui auth login' to authenticate")
|
||||
console.print(" 2. Run 'openwebui models list' to see available models")
|
||||
console.print(" 3. Run 'openwebui chat send -m <model> -p \"Hello\"' to chat")
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(ctx: typer.Context) -> None:
|
||||
"""Show current configuration."""
|
||||
config_path = get_config_path()
|
||||
|
||||
if not config_path.exists():
|
||||
console.print("[yellow]No config file found. Run 'openwebui config init' first.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = load_config()
|
||||
|
||||
console.print(f"[bold]Config file:[/bold] {config_path}\n")
|
||||
|
||||
# Show profiles
|
||||
table = Table(title="Profiles")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("URI", style="green")
|
||||
table.add_column("Default", style="yellow")
|
||||
|
||||
for name, profile in config.profiles.items():
|
||||
is_default = "✓" if name == config.default_profile else ""
|
||||
table.add_row(name, profile.uri, is_default)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Show defaults
|
||||
console.print("\n[bold]Defaults:[/bold]")
|
||||
console.print(f" Model: {config.defaults.model or '(not set)'}")
|
||||
console.print(f" Format: {config.defaults.format}")
|
||||
console.print(f" Stream: {config.defaults.stream}")
|
||||
console.print(f" Timeout: {config.defaults.timeout}s")
|
||||
|
||||
|
||||
@app.command("set")
|
||||
def set_value(
|
||||
ctx: typer.Context,
|
||||
key: str = typer.Argument(..., help="Config key (e.g., 'defaults.model')"),
|
||||
value: str = typer.Argument(..., help="Value to set"),
|
||||
) -> None:
|
||||
"""Set a configuration value."""
|
||||
config = load_config()
|
||||
|
||||
parts = key.split(".")
|
||||
if len(parts) == 2:
|
||||
section, field = parts
|
||||
if section == "defaults":
|
||||
if field == "model":
|
||||
config.defaults.model = value
|
||||
elif field == "format":
|
||||
config.defaults.format = value
|
||||
elif field == "stream":
|
||||
config.defaults.stream = value.lower() in ("true", "1", "yes")
|
||||
elif field == "timeout":
|
||||
config.defaults.timeout = int(value)
|
||||
else:
|
||||
console.print(f"[red]Unknown defaults field: {field}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(f"[red]Unknown section: {section}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print("[red]Key format: section.field (e.g., 'defaults.model')[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
save_config(config)
|
||||
console.print(f"[green]Set {key} = {value}[/green]")
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def get_value(
|
||||
ctx: typer.Context,
|
||||
key: str = typer.Argument(..., help="Config key to get"),
|
||||
) -> None:
|
||||
"""Get a configuration value."""
|
||||
config = load_config()
|
||||
|
||||
parts = key.split(".")
|
||||
if len(parts) == 2:
|
||||
section, field = parts
|
||||
if section == "defaults":
|
||||
value = getattr(config.defaults, field, None)
|
||||
if value is not None:
|
||||
console.print(str(value))
|
||||
else:
|
||||
console.print(f"[red]Unknown field: {field}[/red]")
|
||||
raise typer.Exit(1)
|
||||
elif section == "profiles":
|
||||
profile = config.profiles.get(field)
|
||||
if profile:
|
||||
console.print(f"uri: {profile.uri}")
|
||||
else:
|
||||
console.print(f"[red]Unknown profile: {field}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(f"[red]Unknown section: {section}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print("[red]Key format: section.field (e.g., 'defaults.model')[/red]")
|
||||
raise typer.Exit(1)
|
||||
106
openwebui_cli/commands/models.py
Normal file
106
openwebui_cli/commands/models.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Model management commands."""
|
||||
|
||||
import json
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ..http import create_client, handle_response, handle_request_error
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_models(
|
||||
ctx: typer.Context,
|
||||
provider: str | None = typer.Option(None, "--provider", "-p", help="Filter by provider"),
|
||||
) -> None:
|
||||
"""List available models."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.get("/api/models")
|
||||
data = handle_response(response)
|
||||
|
||||
models = data.get("data", data.get("models", []))
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(models, indent=2))
|
||||
else:
|
||||
table = Table(title="Available Models")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name", style="green")
|
||||
table.add_column("Provider", style="yellow")
|
||||
|
||||
for model in models:
|
||||
model_id = model.get("id", model.get("model", "Unknown"))
|
||||
name = model.get("name", model_id)
|
||||
model_provider = model.get("owned_by", model.get("provider", "-"))
|
||||
|
||||
if provider and provider.lower() not in model_provider.lower():
|
||||
continue
|
||||
|
||||
table.add_row(model_id, name, model_provider)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command()
|
||||
def info(
|
||||
ctx: typer.Context,
|
||||
model_id: str = typer.Argument(..., help="Model ID to inspect"),
|
||||
) -> None:
|
||||
"""Show model details."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.get(f"/api/models/{model_id}")
|
||||
data = handle_response(response)
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(data, indent=2))
|
||||
else:
|
||||
console.print(f"[bold]Model:[/bold] {data.get('id', model_id)}")
|
||||
console.print(f"[bold]Name:[/bold] {data.get('name', '-')}")
|
||||
console.print(f"[bold]Provider:[/bold] {data.get('owned_by', '-')}")
|
||||
|
||||
if params := data.get("parameters"):
|
||||
console.print(f"[bold]Parameters:[/bold] {params}")
|
||||
if context := data.get("context_length"):
|
||||
console.print(f"[bold]Context Length:[/bold] {context}")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@app.command()
|
||||
def pull(
|
||||
ctx: typer.Context,
|
||||
model_name: str = typer.Argument(..., help="Model name to pull"),
|
||||
progress: bool = typer.Option(True, "--progress/--no-progress", help="Show download progress"),
|
||||
) -> None:
|
||||
"""Pull/download a model (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Model pull will be available in v1.1[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def delete(
|
||||
ctx: typer.Context,
|
||||
model_name: str = typer.Argument(..., help="Model name to delete"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
||||
) -> None:
|
||||
"""Delete a model (v1.1 feature - placeholder)."""
|
||||
console.print("[yellow]Model delete will be available in v1.1[/yellow]")
|
||||
256
openwebui_cli/commands/rag.py
Normal file
256
openwebui_cli/commands/rag.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""RAG (Retrieval-Augmented Generation) commands."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ..http import create_client, handle_response, handle_request_error
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
# Sub-apps
|
||||
files_app = typer.Typer(help="File operations")
|
||||
collections_app = typer.Typer(help="Collection operations")
|
||||
|
||||
app.add_typer(files_app, name="files")
|
||||
app.add_typer(collections_app, name="collections")
|
||||
|
||||
|
||||
# Files commands
|
||||
@files_app.command("list")
|
||||
def list_files(ctx: typer.Context) -> None:
|
||||
"""List uploaded files."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.get("/api/v1/files/")
|
||||
data = handle_response(response)
|
||||
|
||||
files = data if isinstance(data, list) else data.get("files", [])
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(files, indent=2))
|
||||
else:
|
||||
table = Table(title="Uploaded Files")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Filename", style="green")
|
||||
table.add_column("Size", style="yellow")
|
||||
|
||||
for f in files:
|
||||
file_id = f.get("id", "-")
|
||||
filename = f.get("filename", f.get("name", "-"))
|
||||
size = f.get("size", "-")
|
||||
if isinstance(size, int):
|
||||
size = f"{size / 1024:.1f} KB"
|
||||
table.add_row(file_id, filename, str(size))
|
||||
|
||||
console.print(table)
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@files_app.command()
|
||||
def upload(
|
||||
ctx: typer.Context,
|
||||
paths: list[Path] = typer.Argument(..., help="File path(s) to upload"),
|
||||
collection: str | None = typer.Option(None, "--collection", "-c", help="Add to collection"),
|
||||
) -> None:
|
||||
"""Upload file(s) for RAG."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
timeout=300, # Longer timeout for uploads
|
||||
) as client:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
console.print(f"[red]File not found: {path}[/red]")
|
||||
continue
|
||||
|
||||
with open(path, "rb") as f:
|
||||
files = {"file": (path.name, f)}
|
||||
response = client.post("/api/v1/files/", files=files)
|
||||
|
||||
data = handle_response(response)
|
||||
file_id = data.get("id", "unknown")
|
||||
console.print(f"[green]Uploaded:[/green] {path.name} (id: {file_id})")
|
||||
|
||||
# Add to collection if specified
|
||||
if collection and file_id != "unknown":
|
||||
try:
|
||||
client.post(
|
||||
f"/api/v1/knowledge/{collection}/file/add",
|
||||
json={"file_id": file_id},
|
||||
)
|
||||
console.print(f" Added to collection: {collection}")
|
||||
except Exception as e:
|
||||
console.print(f" [yellow]Warning: Could not add to collection: {e}[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@files_app.command()
|
||||
def delete(
|
||||
ctx: typer.Context,
|
||||
file_id: str = typer.Argument(..., help="File ID to delete"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
||||
) -> None:
|
||||
"""Delete an uploaded file."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(f"Delete file {file_id}?")
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.delete(f"/api/v1/files/{file_id}")
|
||||
handle_response(response)
|
||||
console.print(f"[green]Deleted file: {file_id}[/green]")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
# Collections commands
|
||||
@collections_app.command("list")
|
||||
def list_collections(ctx: typer.Context) -> None:
|
||||
"""List knowledge collections."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.get("/api/v1/knowledge/")
|
||||
data = handle_response(response)
|
||||
|
||||
collections = data if isinstance(data, list) else data.get("collections", [])
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(collections, indent=2))
|
||||
else:
|
||||
table = Table(title="Knowledge Collections")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name", style="green")
|
||||
table.add_column("Description", style="yellow")
|
||||
|
||||
for c in collections:
|
||||
coll_id = c.get("id", "-")
|
||||
name = c.get("name", "-")
|
||||
desc = c.get("description", "-")[:50]
|
||||
table.add_row(coll_id, name, desc)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@collections_app.command()
|
||||
def create(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Collection name"),
|
||||
description: str = typer.Option("", "--description", "-d", help="Collection description"),
|
||||
) -> None:
|
||||
"""Create a knowledge collection."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.post(
|
||||
"/api/v1/knowledge/",
|
||||
json={"name": name, "description": description},
|
||||
)
|
||||
data = handle_response(response)
|
||||
coll_id = data.get("id", "unknown")
|
||||
console.print(f"[green]Created collection:[/green] {name} (id: {coll_id})")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
@collections_app.command()
|
||||
def delete(
|
||||
ctx: typer.Context,
|
||||
collection_id: str = typer.Argument(..., help="Collection ID to delete"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
||||
) -> None:
|
||||
"""Delete a knowledge collection."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(f"Delete collection {collection_id}?")
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.delete(f"/api/v1/knowledge/{collection_id}")
|
||||
handle_response(response)
|
||||
console.print(f"[green]Deleted collection: {collection_id}[/green]")
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
|
||||
|
||||
# Search command
|
||||
@app.command()
|
||||
def search(
|
||||
ctx: typer.Context,
|
||||
query: str = typer.Argument(..., help="Search query"),
|
||||
collection: str = typer.Option(..., "--collection", "-c", help="Collection ID to search"),
|
||||
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results"),
|
||||
) -> None:
|
||||
"""Search within a collection (vector search)."""
|
||||
obj = ctx.obj or {}
|
||||
|
||||
try:
|
||||
with create_client(
|
||||
profile=obj.get("profile"),
|
||||
uri=obj.get("uri"),
|
||||
) as client:
|
||||
response = client.post(
|
||||
f"/api/v1/knowledge/{collection}/query",
|
||||
json={"query": query, "k": top_k},
|
||||
)
|
||||
data = handle_response(response)
|
||||
|
||||
results = data.get("results", data.get("documents", []))
|
||||
|
||||
if obj.get("format") == "json":
|
||||
console.print(json.dumps(results, indent=2))
|
||||
else:
|
||||
console.print(f"[bold]Search results for:[/bold] {query}\n")
|
||||
for i, result in enumerate(results, 1):
|
||||
content = result.get("content", result.get("text", str(result)))[:200]
|
||||
score = result.get("score", result.get("distance", "-"))
|
||||
console.print(f"[cyan]{i}.[/cyan] (score: {score})")
|
||||
console.print(f" {content}...")
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
handle_request_error(e)
|
||||
113
openwebui_cli/config.py
Normal file
113
openwebui_cli/config.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Configuration management for OpenWebUI CLI."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Get the configuration directory path (XDG-compliant)."""
|
||||
if os.name == "nt": # Windows
|
||||
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||
else: # Unix/Linux/macOS
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return base / "openwebui"
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the configuration file path."""
|
||||
return get_config_dir() / "config.yaml"
|
||||
|
||||
|
||||
class ProfileConfig(BaseModel):
|
||||
"""Configuration for a single server profile."""
|
||||
|
||||
uri: str = "http://localhost:8080"
|
||||
# Token stored in keyring, not in config file
|
||||
|
||||
|
||||
class DefaultsConfig(BaseModel):
|
||||
"""Default settings for CLI commands."""
|
||||
|
||||
model: str | None = None
|
||||
format: str = "text"
|
||||
stream: bool = True
|
||||
timeout: int = 30
|
||||
|
||||
|
||||
class OutputConfig(BaseModel):
|
||||
"""Output formatting preferences."""
|
||||
|
||||
colors: bool = True
|
||||
progress_bars: bool = True
|
||||
timestamps: bool = False
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Main configuration model."""
|
||||
|
||||
version: int = 1
|
||||
default_profile: str = "default"
|
||||
profiles: dict[str, ProfileConfig] = Field(default_factory=lambda: {"default": ProfileConfig()})
|
||||
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
|
||||
output: OutputConfig = Field(default_factory=OutputConfig)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Environment-based settings that override config file."""
|
||||
|
||||
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."""
|
||||
config_path = get_config_path()
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return Config(**data)
|
||||
else:
|
||||
return Config()
|
||||
|
||||
|
||||
def save_config(config: Config) -> None:
|
||||
"""Save configuration to file."""
|
||||
config_path = get_config_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def get_effective_config(
|
||||
profile: str | None = None,
|
||||
uri: str | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""
|
||||
Get effective URI and profile name, respecting precedence:
|
||||
CLI flags > env vars > config file > defaults
|
||||
"""
|
||||
config = load_config()
|
||||
settings = Settings()
|
||||
|
||||
# Determine profile
|
||||
effective_profile = profile or settings.openwebui_profile or config.default_profile
|
||||
|
||||
# Get profile config
|
||||
profile_config = config.profiles.get(effective_profile, ProfileConfig())
|
||||
|
||||
# Determine URI with precedence
|
||||
effective_uri = uri or settings.openwebui_uri or profile_config.uri
|
||||
|
||||
return effective_uri, effective_profile
|
||||
60
openwebui_cli/errors.py
Normal file
60
openwebui_cli/errors.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""CLI error classes with standardized exit codes."""
|
||||
|
||||
import sys
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class ExitCode(IntEnum):
|
||||
"""Standardized exit codes for the CLI."""
|
||||
|
||||
SUCCESS = 0
|
||||
GENERAL_ERROR = 1
|
||||
USAGE_ERROR = 2
|
||||
AUTH_ERROR = 3
|
||||
NETWORK_ERROR = 4
|
||||
SERVER_ERROR = 5
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Base exception for CLI errors."""
|
||||
|
||||
exit_code: int = ExitCode.GENERAL_ERROR
|
||||
|
||||
def __init__(self, message: str, exit_code: int | None = None):
|
||||
super().__init__(message)
|
||||
if exit_code is not None:
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class UsageError(CLIError):
|
||||
"""Invalid command usage or arguments."""
|
||||
|
||||
exit_code = ExitCode.USAGE_ERROR
|
||||
|
||||
|
||||
class AuthError(CLIError):
|
||||
"""Authentication or authorization failure."""
|
||||
|
||||
exit_code = ExitCode.AUTH_ERROR
|
||||
|
||||
|
||||
class NetworkError(CLIError):
|
||||
"""Network connectivity or timeout error."""
|
||||
|
||||
exit_code = ExitCode.NETWORK_ERROR
|
||||
|
||||
|
||||
class ServerError(CLIError):
|
||||
"""Server returned an error (5xx)."""
|
||||
|
||||
exit_code = ExitCode.SERVER_ERROR
|
||||
|
||||
|
||||
def handle_error(error: Exception) -> int:
|
||||
"""Handle an error and return the appropriate exit code."""
|
||||
if isinstance(error, CLIError):
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
return error.exit_code
|
||||
else:
|
||||
print(f"Unexpected error: {error}", file=sys.stderr)
|
||||
return ExitCode.GENERAL_ERROR
|
||||
1
openwebui_cli/formatters/__init__.py
Normal file
1
openwebui_cli/formatters/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Output formatters."""
|
||||
151
openwebui_cli/http.py
Normal file
151
openwebui_cli/http.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""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'")
|
||||
elif response.status_code == 403:
|
||||
raise AuthError("Permission denied. Check your access rights.")
|
||||
elif response.status_code >= 500:
|
||||
raise ServerError(f"Server error: {response.status_code} - {response.text}")
|
||||
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}")
|
||||
|
||||
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}")
|
||||
elif isinstance(error, httpx.TimeoutException):
|
||||
raise NetworkError(f"Request timed out: {error}")
|
||||
elif isinstance(error, httpx.RequestError):
|
||||
raise NetworkError(f"Request failed: {error}")
|
||||
else:
|
||||
raise error
|
||||
56
openwebui_cli/main.py
Normal file
56
openwebui_cli/main.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Main CLI entry point."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from . import __version__
|
||||
from .commands import auth, chat, config_cmd, models, rag, admin
|
||||
|
||||
# Create main app
|
||||
app = typer.Typer(
|
||||
name="openwebui",
|
||||
help="Official CLI for OpenWebUI",
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
# Create console for rich output
|
||||
console = Console()
|
||||
|
||||
# Register sub-commands
|
||||
app.add_typer(auth.app, name="auth", help="Authentication commands")
|
||||
app.add_typer(chat.app, name="chat", help="Chat operations")
|
||||
app.add_typer(models.app, name="models", help="Model management")
|
||||
app.add_typer(rag.app, name="rag", help="RAG file and collection operations")
|
||||
app.add_typer(admin.app, name="admin", help="Admin operations (requires admin role)")
|
||||
app.add_typer(config_cmd.app, name="config", help="CLI configuration")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
||||
profile: str | None = typer.Option(None, "--profile", "-P", help="Use named profile"),
|
||||
uri: str | None = typer.Option(None, "--uri", "-U", help="Server URI"),
|
||||
format: str | None = typer.Option(None, "--format", "-f", help="Output format: text, json, yaml"),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "--debug", help="Enable debug logging"),
|
||||
timeout: int | None = typer.Option(None, "--timeout", "-t", help="Request timeout in seconds"),
|
||||
) -> None:
|
||||
"""OpenWebUI CLI - interact with your OpenWebUI instance from the command line."""
|
||||
if version:
|
||||
console.print(f"openwebui-cli version {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
# Store global options in context for subcommands
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["profile"] = profile
|
||||
ctx.obj["uri"] = uri
|
||||
ctx.obj["format"] = format or "text"
|
||||
ctx.obj["quiet"] = quiet
|
||||
ctx.obj["verbose"] = verbose
|
||||
ctx.obj["timeout"] = timeout
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
1
openwebui_cli/utils/__init__.py
Normal file
1
openwebui_cli/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Utility modules."""
|
||||
72
pyproject.toml
Normal file
72
pyproject.toml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openwebui-cli"
|
||||
version = "0.1.0"
|
||||
description = "Official CLI for OpenWebUI - interact with your OpenWebUI instance from the command line"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "InfraFabric Team" },
|
||||
]
|
||||
keywords = ["openwebui", "cli", "llm", "ai", "chat"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
dependencies = [
|
||||
"typer>=0.9.0",
|
||||
"httpx>=0.25.0",
|
||||
"rich>=13.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"pyyaml>=6.0",
|
||||
"keyring>=24.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openwebui = "openwebui_cli.main:app"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/dannystocker/openwebui-cli"
|
||||
Documentation = "https://github.com/dannystocker/openwebui-cli#readme"
|
||||
Repository = "https://github.com/dannystocker/openwebui-cli.git"
|
||||
Issues = "https://github.com/dannystocker/openwebui-cli/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["openwebui_cli"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for openwebui-cli."""
|
||||
52
tests/test_config.py
Normal file
52
tests/test_config.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tests for configuration module."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openwebui_cli.config import Config, ProfileConfig, load_config, save_config
|
||||
|
||||
|
||||
def test_default_config():
|
||||
"""Test default configuration values."""
|
||||
config = Config()
|
||||
|
||||
assert config.version == 1
|
||||
assert config.default_profile == "default"
|
||||
assert "default" in config.profiles
|
||||
assert config.defaults.format == "text"
|
||||
assert config.defaults.stream is True
|
||||
assert config.defaults.timeout == 30
|
||||
|
||||
|
||||
def test_profile_config():
|
||||
"""Test profile configuration."""
|
||||
profile = ProfileConfig(uri="https://example.com")
|
||||
|
||||
assert profile.uri == "https://example.com"
|
||||
|
||||
|
||||
def test_config_roundtrip(tmp_path, monkeypatch):
|
||||
"""Test saving and loading config."""
|
||||
config_dir = tmp_path / "openwebui"
|
||||
config_path = config_dir / "config.yaml"
|
||||
|
||||
# Monkey-patch the config path functions
|
||||
monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir)
|
||||
monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path)
|
||||
|
||||
# Create and save config
|
||||
config = Config(
|
||||
default_profile="test",
|
||||
profiles={"test": ProfileConfig(uri="https://test.example.com")},
|
||||
)
|
||||
config.defaults.model = "test-model"
|
||||
|
||||
save_config(config)
|
||||
|
||||
# Load and verify
|
||||
loaded = load_config()
|
||||
assert loaded.default_profile == "test"
|
||||
assert loaded.profiles["test"].uri == "https://test.example.com"
|
||||
assert loaded.defaults.model == "test-model"
|
||||
47
tests/test_errors.py
Normal file
47
tests/test_errors.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Tests for error handling."""
|
||||
|
||||
import pytest
|
||||
|
||||
from openwebui_cli.errors import (
|
||||
AuthError,
|
||||
CLIError,
|
||||
ExitCode,
|
||||
NetworkError,
|
||||
ServerError,
|
||||
UsageError,
|
||||
handle_error,
|
||||
)
|
||||
|
||||
|
||||
def test_exit_codes():
|
||||
"""Test exit code values."""
|
||||
assert ExitCode.SUCCESS == 0
|
||||
assert ExitCode.GENERAL_ERROR == 1
|
||||
assert ExitCode.USAGE_ERROR == 2
|
||||
assert ExitCode.AUTH_ERROR == 3
|
||||
assert ExitCode.NETWORK_ERROR == 4
|
||||
assert ExitCode.SERVER_ERROR == 5
|
||||
|
||||
|
||||
def test_cli_error():
|
||||
"""Test base CLI error."""
|
||||
error = CLIError("Test error")
|
||||
assert str(error) == "Test error"
|
||||
assert error.exit_code == ExitCode.GENERAL_ERROR
|
||||
|
||||
|
||||
def test_error_classes():
|
||||
"""Test specific error classes have correct exit codes."""
|
||||
assert UsageError("test").exit_code == ExitCode.USAGE_ERROR
|
||||
assert AuthError("test").exit_code == ExitCode.AUTH_ERROR
|
||||
assert NetworkError("test").exit_code == ExitCode.NETWORK_ERROR
|
||||
assert ServerError("test").exit_code == ExitCode.SERVER_ERROR
|
||||
|
||||
|
||||
def test_handle_error():
|
||||
"""Test error handling returns correct exit codes."""
|
||||
assert handle_error(UsageError("test")) == ExitCode.USAGE_ERROR
|
||||
assert handle_error(AuthError("test")) == ExitCode.AUTH_ERROR
|
||||
assert handle_error(NetworkError("test")) == ExitCode.NETWORK_ERROR
|
||||
assert handle_error(ServerError("test")) == ExitCode.SERVER_ERROR
|
||||
assert handle_error(Exception("test")) == ExitCode.GENERAL_ERROR
|
||||
Loading…
Add table
Reference in a new issue