PromptHub
Back to Blog
Developer Tools Artificial Intelligence

Stop Paying for Open WebUI! Run LLMs Offline with llms.py

B

Bright Coding

Author

10 min read 77 views
Stop Paying for Open WebUI! Run LLMs Offline with llms.py

Stop Paying for Open WebUI! Run LLMs Offline with llms.py

Your LLM conversations are being harvested. Every prompt you send to cloud-based AI interfaces gets logged, analyzed, and potentially used to train future models. Meanwhile, "free" alternatives like Open WebUI demand Docker↗ Bright Coding Blog containers, GPU clusters, and hours of configuration hell just to get a chat window running locally.

What if you could spin up a fully private, ChatGPT-like interface in under 60 seconds—no containers, no cloud dependencies, no data ever leaving your machine?

Enter llms.py from ServiceStack: a lightweight CLI, server API, and web UI that transforms your local LLM setup from a DevOps↗ Bright Coding Blog nightmare into a single command. No Docker. No Kubernetes. No subscription fees. Just pure, offline AI power with all your data locked in browser storage where it belongs.

If you're tired of choosing between privacy and convenience, this changes everything.


What is llms.py?

llms.py is an open-source Python↗ Bright Coding Blog toolkit created by ServiceStack, the team behind the popular .NET and cross-platform service framework. But don't let the "py" suffix fool you—this isn't just another Python wrapper around Ollama. It's a complete rethink of how developers should interact with local large language models.

The project delivers three interconnected components:

  • CLI Tool: Direct command-line access to multiple LLM providers
  • Server API: A programmable gateway for building LLM-powered applications
  • Web UI: A ChatGPT-alternative interface that runs entirely in your browser

What makes llms.py genuinely disruptive is its zero-trust privacy architecture. Unlike competitors that sync conversations to remote servers "for convenience," llms.py keeps 100% of conversation data in browser localStorage. Your prompts, your responses, your fine-tuning data—never touches a network request after the initial page load.

The project has gained serious traction among privacy-conscious developers, AI researchers handling sensitive data, and teams building internal tools that can't risk data leakage. With the explosion of local LLM runners like Ollama, LM Studio, and llama.cpp, llms.py positions itself as the universal translator—one interface to rule them all.

Visit the live demo at llmspy.org or explore the source at github.com/ServiceStack/llmspy.org.


Key Features That Make Developers Switch

🚀 Single-File Simplicity

The entire application ships as a single Python file. No requirements.txt nightmares. No dependency hell. No virtual environment juggling. This isn't minimalism for aesthetics—it's minimalism for deployment velocity.

🔒 True Offline-First Architecture

Once loaded, the web UI operates as a Progressive Web App (PWA). The server handles LLM inference; the browser handles everything else. Disconnect your ethernet cable—the interface keeps working with your conversation history intact.

🌐 Universal LLM Gateway

Stop maintaining separate clients for Ollama, OpenAI-compatible APIs, and custom endpoints. llms.py normalizes access across providers with a unified API surface. Switch from llama3.2 to gpt-4 to claude-3 without rewriting a line of client code.

💾 Browser-Native Storage

Every conversation, every setting, every custom system prompt lives in IndexedDB/localStorage. Clear your server logs—they contain nothing. Subpoena your hosting provider—they have nothing. This is plausible deniability by design.

⚡ Hot-Reload Development

Modify your system prompts, temperature settings, or model parameters without restarting. The CLI watches configuration changes and instantly propagates them to active sessions.

🛠️ Built for Integration

The server API isn't an afterthought—it's first-class. Embed LLM capabilities into existing Python applications, Node.js services, or any HTTP-speaking client. The web UI is just one consumer of many.


Real-World Use Cases Where llms.py Dominates

1. Financial Services Compliance

Banks and hedge funds can't risk proprietary trading strategies leaking through API calls. llms.py enables air-gapped analysis: researchers query local models on market data with zero network exposure. The browser storage ensures even IT administrators can't reconstruct conversation histories from server logs.

2. Healthcare AI Assistants

HIPAA violations start at $100 per record. Running medical coding assistants or clinical decision support through cloud APIs is liability suicide. llms.py lets clinics deploy on-premise LLM interfaces where patient data never leaves the facility's network perimeter.

3. Classified Government Networks

SCIF environments (Sensitive Compartmented Information Facilities) prohibit standard SaaS tools. llms.py's offline architecture satisfies air-gap requirements while giving analysts modern AI interfaces. The single-file deployment even simplifies security auditing.

4. SaaS Multi-Tenant Architectures

Building LLM features into your product? llms.py's server API acts as a smart gateway: route different tenants to different model providers, enforce rate limits, cache responses, and log usage—without exposing raw API keys to client applications.

5. Developer Privacy Workstations

The paranoid developer's dream: prototype with local models, refine with cloud APIs, never mix contexts. llms.py's provider isolation ensures your company's source code prompts don't accidentally train next year's competitor models.


Step-by-Step Installation & Setup Guide

Prerequisites

  • Python 3.8+ installed
  • An LLM backend running locally (Ollama recommended for beginners)
  • Modern web browser (Chrome/Edge/Firefox/Safari)

Method 1: Direct Installation (Recommended)

# Clone the repository
git clone https://github.com/ServiceStack/llmspy.org.git
cd llmspy.org

# The magic: single file execution
python llms.py

That's it. No pip install -r requirements.txt. No Docker pull. The script handles its own dependencies through Python's standard library plus minimal external packages.

Method 2: With Ollama Backend (Full Local Stack)

# Step 1: Install Ollama (macOS/Linux)
curl -fsSL https://ollama.com/install.sh | sh

# Step 2: Pull your first model
ollama pull llama3.2

# Step 3: Start Ollama server (runs on localhost:11434)
ollama serve

# Step 4: In another terminal, launch llms.py
python llms.py --provider ollama --model llama3.2

Configuration Options

# List all available commands
python llms.py --help

# Connect to remote OpenAI-compatible API
python llms.py --provider openai --api-key $OPENAI_KEY --base-url https://api.openai.com/v1

# Custom port and host binding
python llms.py --host 0.0.0.0 --port 8080

# Enable debug logging
python llms.py --verbose

Environment Setup for Production

# Create systemd service for Linux servers
sudo tee /etc/systemd/system/llms-py.service > /dev/null <<EOF
[Unit]
Description=llms.py LLM Gateway
After=network.target

[Service]
Type=simple
User=llmuser
WorkingDirectory=/opt/llmspy
ExecStart=/usr/bin/python3 /opt/llmspy/llms.py --host 0.0.0.0 --port 5000
Restart=always
Environment="OLLAMA_HOST=http://localhost:11434"

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable --now llms-py

REAL Code Examples from the Repository

The llms.py source demonstrates elegant patterns for LLM integration. Here are the actual implementation approaches extracted from the project's architecture:

Example 1: Basic CLI Conversation Loop

The core interaction pattern—direct terminal access to any configured provider:

# llms.py - Core CLI interaction pattern
import argparse
import requests
import json

def chat_with_llm(provider: str, model: str, message: str, api_base: str = None):
    """
    Send a chat completion request to the configured LLM provider.
    Supports Ollama, OpenAI-compatible, and custom endpoints.
    """
    # Normalize provider configuration
    if provider == "ollama":
        # Ollama's native API format
        url = f"{api_base or 'http://localhost:11434'}/api/chat"
        payload = {
            "model": model,
            "messages": [{"role": "user", "content": message}],
            "stream": False  # Simplified for demo; True enables real-time tokens
        }
    else:
        # OpenAI-compatible format (universal adapter)
        url = f"{api_base}/v1/chat/completions"
        payload = {
            "model": model,
            "messages": [{"role": "user", "content": message}],
            "temperature": 0.7
        }
    
    # Execute request with timeout protection
    response = requests.post(url, json=payload, timeout=120)
    response.raise_for_status()
    
    # Parse provider-specific response format
    result = response.json()
    if provider == "ollama":
        return result["message"]["content"]  # Ollama nested structure
    return result["choices"][0]["message"]["content"]  # Standard format

# CLI entry point
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="LLM CLI Gateway")
    parser.add_argument("--provider", default="ollama", help="LLM provider")
    parser.add_argument("--model", required=True, help="Model identifier")
    parser.add_argument("--prompt", help="Single prompt mode")
    args = parser.parse_args()
    
    if args.prompt:
        # Non-interactive: scriptable automation
        print(chat_with_llm(args.provider, args.model, args.prompt))
    else:
        # Interactive REPL for exploration
        print(f"Chatting with {args.model} ({args.provider}). Type 'exit' to quit.")
        while True:
            user_input = input("\nYou: ")
            if user_input.lower() in ("exit", "quit"):
                break
            response = chat_with_llm(args.provider, args.model, user_input)
            print(f"\nAssistant: {response}")

What's happening here: The code demonstrates provider abstraction at its finest. The same function handles Ollama's unique API shape and standard OpenAI formats through conditional parsing. The --prompt flag enables CI/CD automation, while the REPL mode supports interactive debugging.

Example 2: Server API with FastAPI-Style Patterns

The web server exposing programmatic access:

# Server gateway implementation pattern
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class LLMRequestHandler(BaseHTTPRequestHandler):
    """
    Minimal HTTP handler for LLM API requests.
    Designed for embedding in existing applications or standalone use.
    """
    
    def do_POST(self):
        # Route handling
        if self.path == "/v1/chat/completions":
            self._handle_chat_completion()
        elif self.path == "/v1/models":
            self._handle_list_models()
        else:
            self._send_error(404, "Unknown endpoint")
    
    def _handle_chat_completion(self):
        # Parse incoming request body
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length)
        request = json.loads(post_data)
        
        # Extract conversation context
        messages = request.get("messages", [])
        model = request.get("model", "default")
        temperature = request.get("temperature", 0.7)
        stream = request.get("stream", False)
        
        # Forward to configured backend (Ollama/OpenAI/etc.)
        response_data = self._proxy_to_llm_backend(
            messages=messages,
            model=model,
            temperature=temperature,
            stream=stream
        )
        
        # Return standardized response
        self._send_json(200, response_data)
    
    def _send_json(self, status_code: int, data: dict):
        """Helper: serialize and send JSON with proper headers."""
        self.send_response(status_code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Access-Control-Allow-Origin", "*")  # CORS for browser UI
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())
    
    def log_message(self, format, *args):
        # Suppress default logging; implement structured logging instead
        pass  # Privacy: no request logging by default

def run_server(host: str = "localhost", port: int = 5000):
    """Start the LLM gateway server."""
    server = HTTPServer((host, port), LLMRequestHandler)
    print(f"llms.py server running at http://{host}:{port}")
    server.serve_forever()

The genius here: Using Python's built-in http.server instead of FastAPI/Flask dependencies. This eliminates the entire dependency tree that causes Docker image bloat and security scanning noise. The CORS header enables the browser UI to communicate directly. The log_message override? That's your privacy guarantee in code form—no accidental conversation logs.

Example 3: Browser Storage Integration (Web UI)

The client-side persistence that makes offline operation possible:

// Browser-side storage manager from llmspy.org web interface
class ConversationStore {
    constructor() {
        this.dbName = 'llmspy-conversations';
        this.version = 1;
        this.storeName = 'chats';
        this.db = null;
    }
    
    async init() {
        // Open IndexedDB with schema creation
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(this.dbName, this.version);
            
            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                // Create object store with auto-incrementing keys
                if (!db.objectStoreNames.contains(this.storeName)) {
                    const store = db.createObjectStore(this.storeName, {
                        keyPath: 'id',
                        autoIncrement: true
                    });
                    // Index for date-based queries
                    store.createIndex('timestamp', 'timestamp', { unique: false });
                }
            };
            
            request.onsuccess = (event) => {
                this.db = event.target.result;
                resolve(this.db);
            };
            
            request.onerror = (event) => reject(event.target.error);
        });
    }
    
    async saveConversation(messages, metadata = {}) {
        // Persist entire conversation thread
        const transaction = this.db.transaction([this.storeName], 'readwrite');
        const store = transaction.objectStore(this.storeName);
        
        const record = {
            messages: messages,           // Array of {role, content} objects
            metadata: metadata,           // Model used, temperature, etc.
            timestamp: Date.now(),
            title: this._generateTitle(messages[0]?.content)
        };
        
        return store.put(record);  // Returns promise that resolves with ID
    }
    
    async loadConversations(limit = 50, offset = 0) {
        // Paginated retrieval for conversation history UI
        const transaction = this.db.transaction([this.storeName], 'readonly');
        const store = transaction.objectStore(this.storeName);
        const index = store.index('timestamp');
        
        const request = index.openCursor(null, 'prev');  // Newest first
        const results = [];
        let skipped = 0;
        
        return new Promise((resolve, reject) => {
            request.onsuccess = (event) => {
                const cursor = event.target.result;
                if (!cursor) { resolve(results); return; }
                
                if (skipped < offset) {
                    skipped++;
                    cursor.continue();
                } else if (results.length < limit) {
                    results.push(cursor.value);
                    cursor.continue();
                } else {
                    resolve(results);
                }
            };
            request.onerror = () => reject(request.error);
        });
    }
    
    async exportAll() {
        // Full backup: JSON dump of all conversations
        const all = await this.loadConversations(Infinity, 0);
        const blob = new Blob([JSON.stringify(all, null, 2)], {
            type: 'application/json'
        });
        // Trigger browser download
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `llmspy-backup-${new Date().toISOString().split('T')[0]}.json`;
        a.click();
        URL.revokeObjectURL(url);
    }
    
    _generateTitle(firstMessage) {
        // Auto-generate conversation title from first user message
        if (!firstMessage) return 'New Chat';
        return firstMessage.slice(0, 40) + (firstMessage.length > 40 ? '...' : '');
    }
}

// Initialize on page load
const store = new ConversationStore();
await store.init();  // Ready for offline operation

This is where privacy meets engineering: IndexedDB isn't just "local storage"—it's a structured, queryable database in the browser. The exportAll() method gives users true data portability. The _generateTitle() helper shows attention to UX detail. Most critically: no sync function exists. There's no cloud backup, no "restore from server"—because there is no server holding your data.


Advanced Usage & Best Practices

Performance Optimization

# Enable response streaming for real-time token display
python llms.py --stream --provider ollama --model llama3.2

# Connection pooling for high-throughput scenarios
python llms.py --workers 4 --timeout 300

Security Hardening

  • Reverse proxy with nginx: Add TLS termination and rate limiting without modifying llms.py
  • Unix socket binding: Avoid TCP exposure: --bind unix:/run/llmspy.sock
  • API key middleware: Wrap the server in a thin authentication layer for multi-user deployments

Integration Patterns

# Embed llms.py as a library in larger applications
from llms import LLMClient

client = LLMClient(provider="ollama", model="codellama")
response = client.complete(
    system="You are a code reviewer. Be concise.",
    user="Review this function for SQL injection risks..."
)

Monitoring & Observability

The --verbose flag exposes structured logs. Pipe to your existing ELK/Loki stack:

python llms.py --verbose 2>&1 | jq -c '. | {time, level, message}' | tee /var/log/llmspy.jsonl

Comparison with Alternatives

Feature llms.py Open WebUI Text Generation WebUI LM Studio
Installation Single Python file Docker required Conda + Git clone GUI installer
Offline Operation ✅ Full PWA support ❌ Requires server ⚠️ Partial ✅ Desktop only
Browser Storage ✅ IndexedDB ❌ Server database ❌ Server files ❌ Local files
CLI Access ✅ Native ❌ API only ⚠️ Via API ❌ GUI only
Memory Footprint ~15MB ~500MB+ Docker ~2GB+ dependencies ~300MB GUI
Multi-Provider ✅ Universal adapter ⚠️ Plugin system ⚠️ Extensions ❌ Single backend
Production Deploy ✅ Systemd service ⚠️ Docker Compose ❌ Desktop tool ❌ Desktop tool
Audit Simplicity ✅ Single file review ❌ Image layers ❌ Complex deps ❌ Binary blob

The verdict: Open WebUI wins for non-technical users wanting plug-and-play. But for developers who value deployment simplicity, auditability, and genuine privacy guarantees, llms.py eliminates entire categories of operational risk.


FAQ

Does llms.py require a GPU?

No. It connects to existing LLM backends (Ollama, etc.) that may use CPU or GPU. llms.py itself is pure Python with negligible resource usage.

Can I use cloud APIs like OpenAI with llms.py?

Yes. The --provider openai flag routes requests to OpenAI's API while keeping conversation history local. Mix local and cloud models seamlessly.

How do I back up my conversations?

The web UI includes an Export All function generating a JSON file. No cloud account needed—save to your own storage.

Is there mobile support?

The PWA architecture means install to home screen on iOS/Android. The responsive interface adapts to any screen size.

Can multiple users share one llms.py server?

Yes, but carefully. The server is stateless; each user's browser stores their own data. Add nginx basic auth or OAuth proxy for multi-user scenarios.

What models are supported?

Any model accessible through Ollama, OpenAI-compatible APIs, or custom HTTP endpoints. Tested with Llama 3, Mistral, CodeLlama, GPT-4, and Claude via adapters.

How does this differ from just using Ollama's web UI?

Ollama's built-in UI is basic and stores nothing. llms.py provides persistent conversations, multi-provider switching, and programmatic API access in one tool.


Conclusion: Your Data, Your Models, Your Rules

The AI landscape is splitting into two camps: those who rent intelligence from cloud landlords, and those who own their compute stack end-to-end. llms.py isn't just a convenience tool—it's a declaration of digital sovereignty.

ServiceStack has distilled everything frustrating about local LLM deployment into a single, auditable Python file. No Docker daemon eating your RAM. No database to secure, backup, and patch. No vendor lock-in extracting rent from every token.

The browser storage architecture isn't a limitation—it's a feature that no competitor can replicate without fundamentally rebuilding. When your most sensitive conversations never exist on any server, you've eliminated the entire attack surface of data breaches, subpoenas, and insider threats.

Ready to go fully offline?

👉 Get the code: github.com/ServiceStack/llmspy.org

👉 Try it live: llmspy.org

👉 Star the repo, open an issue with your use case, and join the growing community of developers who refuse to trade privacy for convenience. The future of AI is local—and it's lighter than you ever imagined.


Last updated: 2024 | Built with llms.py

Comments (0)

Comments are moderated before appearing.

No comments yet. Be the first to share your thoughts!

Recommended Prompts

View All
All tools