diff --git a/.env.example b/.env.example index e510c04..3e636c4 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,86 @@ -# llms-py Environment Variables -# Copy this file to .env and fill in your API keys +# llms-py API keys +# +# Copy to .env and fill in the providers you have keys for: cp .env.example .env +# One VAR=value per line, no quotes, no spaces around '='. +# +# Or let the installer generate ~/.llms/.env for you: +# curl -fsSL https://llmspy.org/install.sh | bash +# +# Providers with no key set are skipped at startup. -# OpenRouter (Free tier available) -OPENROUTER_API_KEY= +# Alibaba — https://www.alibabacloud.com/help/en/model-studio/models +DASHSCOPE_API_KEY= -# Groq (Free tier available) -GROQ_API_KEY= +# Anthropic — https://docs.anthropic.com/en/docs/about-claude/models +ANTHROPIC_API_KEY= + +# Cerebras — https://inference-docs.cerebras.ai/models/overview +CEREBRAS_API_KEY= -# Google Free API (Free tier available) -GOOGLE_FREE_API_KEY= +# Chutes — https://llm.chutes.ai/v1/models +CHUTES_API_KEY= -# Codestral (Free tier available) +# codestral CODESTRAL_API_KEY= -# Google Gemini API +# DeepSeek — https://api-docs.deepseek.com/quick_start/pricing +DEEPSEEK_API_KEY= + +# Fireworks AI — https://fireworks.ai/docs/ +FIREWORKS_API_KEY= + +# GitHub Copilot — https://docs.github.com/en/copilot +GITHUB_TOKEN= + +# Google — https://ai.google.dev/gemini-api/docs/models GOOGLE_API_KEY= +# GOOGLE_GENERATIVE_AI_API_KEY= +# GEMINI_API_KEY= -# Anthropic Claude API -ANTHROPIC_API_KEY= +# Groq — https://console.groq.com/docs/models +GROQ_API_KEY= + +# Hugging Face — https://huggingface.co/docs/inference-providers +HF_TOKEN= + +# llmspy +LLMSPY_API_KEY= -# OpenAI API +# MiniMax (minimax.io) — https://platform.minimax.io/docs/guides/quickstart +MINIMAX_API_KEY= + +# Mistral — https://docs.mistral.ai/getting-started/models/ +MISTRAL_API_KEY= + +# Moonshot AI — https://platform.moonshot.ai/docs/api/chat +MOONSHOT_API_KEY= + +# Nvidia — https://docs.api.nvidia.com/nim/ +NVIDIA_API_KEY= + +# Ollama Cloud — https://docs.ollama.com/cloud +OLLAMA_API_KEY= + +# OpenAI — https://platform.openai.com/docs/models OPENAI_API_KEY= -# Grok (X.AI) API -GROK_API_KEY= +# OpenRouter — https://openrouter.ai/models +OPENROUTER_API_KEY= -# Qwen (Alibaba DashScope) API -DASHSCOPE_API_KEY= +# xAI — https://docs.x.ai/docs/models +XAI_API_KEY= -# Z.ai API -ZAI_API_KEY= +# Z.AI / Z.AI Coding Plan — https://docs.z.ai/guides/overview/pricing +ZHIPU_API_KEY= -# Mistral API -MISTRAL_API_KEY= +# --------------------------------------------------------------------------- +# Local providers — no key needed unless you've set one. +# From Docker, point them at host.docker.internal instead of localhost. +# --------------------------------------------------------------------------- + +# LMStudio — https://lmstudio.ai/models +LMSTUDIO_API_KEY= + +# openai-local +OPENAI_LOCAL_API_KEY= diff --git a/DOCKER.md b/DOCKER.md index 5ae8df8..e2ae6e1 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,458 +1,338 @@ -# Docker Support for llms-py +# Running llms-py in Docker -This document provides detailed information about running llms-py in Docker. +The Docker image bundles llms.py with everything its extensions need — Python, +`bun`, and the .NET SDK — so nothing has to be installed on the host beyond +Docker itself. -## Quick Start +- [Quick start](#quick-start) — the one-line installer +- [What the installer does](#what-the-installer-does) +- [The `llms` command](#the-llms-command) +- [Configuration](#configuration) +- [Running without the installer](#running-without-the-installer) +- [Building and testing locally](#building-and-testing-locally) +- [Troubleshooting](#troubleshooting) -### Using Pre-built Images +## Quick start ```bash -# Pull and run the latest image -docker pull ghcr.io/servicestack/llms:latest -docker run -p 8000:8000 -e OPENROUTER_API_KEY="your-key" ghcr.io/servicestack/llms:latest +curl -fsSL https://llmspy.org/install.sh | bash ``` -### Using docker-compose (Recommended) +This pulls the latest image, installs an `llms` command on your PATH, and opens +a setup screen where you pick which providers to enable and paste in API keys. +Then: -1. Create a `.env` file with your API keys: ```bash -OPENROUTER_API_KEY=sk-or-... -GROQ_API_KEY=gsk_... -GOOGLE_FREE_API_KEY=AIza... -ANTHROPIC_API_KEY=sk-ant-... -OPENAI_API_KEY=sk-... -GROK_API_KEY=xai-... -DASHSCOPE_API_KEY=sk-... -ZAI_API_KEY=sk-... -MISTRAL_API_KEY=... +llms up # start the server on http://localhost:8000 +llms ls # list enabled providers and models +llms "what is 2+2?" # ask the default model ``` -2. Start the service: -```bash -docker-compose up -d -``` +Re-run the same command any time to update to the latest image — your config and +API keys are left alone: -3. Access the UI at http://localhost:8000 - -## Files Created - -### Dockerfile -Multi-stage Docker build that: -- Uses Python 3.11 slim base image -- Builds the package from source -- Installs `bun` runtime -- Installs `dotnet-sdk` 10.0 -- Runs as non-root user for security -- Includes health checks -- Exposes port 8000 -- Default command: `llms --serve 8000` - -### .dockerignore -Excludes unnecessary files from the Docker build context to reduce image size and build time. - -### docker-compose.yml -Provides easy orchestration with: -- Port mapping (8000:8000) -- Environment variable support via .env file -- Named volume for data persistence -- Automatic restart policy -- Health checks - -### docker-build.sh -Convenience script for building the Docker image locally: ```bash -./docker-build.sh [tag] +curl -fsSL https://llmspy.org/install.sh | bash ``` -### .github/workflows/docker-publish.yml -GitHub Actions workflow that: -- Builds Docker images on push to main and tags -- Publishes to GitHub Container Registry (ghcr.io) -- Supports multi-architecture builds (amd64, arm64) -- Creates image tags for branches, PRs, and semantic versions -- Uses Docker layer caching for faster builds - -## Usage Examples +### Installer options -### Basic Server +Pass options after `| bash -s --`: ```bash -docker run -p 8000:8000 \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest +curl -fsSL https://llmspy.org/install.sh | bash -s -- --port 3000 ``` -### With Multiple API Keys - -```bash -docker run -p 8000:8000 \ - -e OPENROUTER_API_KEY="sk-or-..." \ - -e GROQ_API_KEY="gsk_..." \ - -e GOOGLE_FREE_API_KEY="AIza..." \ - -e ANTHROPIC_API_KEY="sk-ant-..." \ - ghcr.io/servicestack/llms:latest -``` +| Option | Description | +| --- | --- | +| `--no-setup` | Don't open the provider setup screen | +| `--no-pull` | Skip pulling the image | +| `--setup-only` | Just re-open the provider setup screen | +| `--image IMAGE` | Image to use (default `ghcr.io/servicestack/llms:latest`) | +| `--port PORT` | Host port for the server (default `8000`) | +| `--bind ADDR` | Host address to publish on (default `127.0.0.1`) | +| `--dir DIR` | Config directory (default `~/.llms`) | +| `--bin-dir DIR` | Where to install the `llms` command | +| `--uninstall` | Remove the command and container (keeps your config) | -### With Persistent Storage +## What the installer does -```bash -docker run -p 8000:8000 \ - -v llms-data:/home/llms/.llms \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest -``` +1. Checks Docker is installed and running, with platform-specific install hints if not. +2. Pulls `ghcr.io/servicestack/llms:latest` and reports whether anything changed. +3. Runs `llms --init` in a throwaway container to create `~/.llms/llms.json` and + `~/.llms/providers.json` (skipped if they already exist). +4. Writes `~/.llms/config`, `~/.llms/.env` and an optional `~/.llms/docker-compose.yml`. +5. Installs `~/.llms/bin/llms` and `~/.llms/bin/llms-setup`, and symlinks `llms` + into `~/.local/bin` (or `/usr/local/bin`, or `~/bin`). +6. Opens the provider setup screen. -### CLI Usage +Nothing is written outside `~/.llms` and the bin directory, and nothing needs `sudo`. -```bash -# Single query -docker run --rm \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest \ - llms "What is the capital of France?" - -# List models -docker run --rm \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest \ - llms --list - -# Check provider -docker run --rm \ - -e GROQ_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest \ - llms --check groq -``` +### If you already have the pip package installed -### Custom Port +If a different `llms` is already on your PATH — usually `pip install llms-py` — +the installer says so and additionally installs the Docker version as +`llms-docker`, leaving your existing `llms` as the winner. Use whichever you +prefer; they share nothing except the `~/.llms` config directory. -```bash -# Run on port 3000 -docker run -p 3000:8000 \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest -``` +### Provider setup screen -### With Verbose Logging +`llms setup` (or the installer) opens a terminal picker listing every provider in +`providers.json`: -```bash -docker run -p 8000:8000 \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest \ - llms --serve 8000 --verbose ``` + llms.py — providers -## Building Locally - -### Using the Build Script + ● Anthropic ANTHROPIC_API_KEY saved sk-a..7Yq2 13 + ○ Cerebras CEREBRAS_API_KEY - - 3 + ● Groq GROQ_API_KEY shell gsk_..1f9c 15 + ○ Ollama - local - 0 + ○ OpenAI OPENAI_API_KEY - - 47 -```bash -./docker-build.sh + ↑↓ move space toggle enter set key x clear a all n none s save q quit ``` -This builds the image as `llms-py:latest`. - -### Manual Build +The **source** column tells you where each key came from: -```bash -docker build -t llms-py:latest . -``` +| Source | Meaning | +| --- | --- | +| `saved` | Already in `~/.llms/.env` — pre-selected | +| `shell` | Found in the environment you launched from — pre-selected, and copied to `~/.llms/.env` when you save | +| `local` | A provider that runs on your own machine, no key needed | +| `-` | No key yet — press enter to paste one | -### Build with Custom Tag +Keys detected in your shell are pre-selected so the common case is one keystroke +(s). `GITHUB_TOKEN` is the exception: it's listed but never +pre-selected, because it's usually the `gh` CLI's token rather than a Copilot +subscription. -```bash -./docker-build.sh v2.0.24 -``` +Saving writes API keys to `~/.llms/.env` (mode `600`) and flips +`providers.*.enabled` in `~/.llms/llms.json`. Re-run it any time — it always +shows your current state, so it doubles as a way to see which providers are +configured. -## Docker Compose +Enabling a local provider (Ollama, LM Studio) also rewrites its `api` URL from +`localhost` to `host.docker.internal`, since `localhost` inside a container is +the container itself. -### Using Pre-built Image (Recommended for Users) +## The `llms` command -The default `docker-compose.yml` uses the pre-built image from GitHub Container Registry: +The wrapper passes any llms CLI arguments straight through to the container, so +the rest of the documentation applies unchanged: ```bash -# Start services -docker-compose up -d - -# View logs -docker-compose logs -f - -# Stop services -docker-compose down +llms ls # list enabled providers and models +llms ls anthropic # filter to one provider +llms --check groq # verify a provider's models +llms -m gpt-5 "explain monads" # pick a model +llms --image ./photo.png "describe" # the current directory is mounted at /work ``` -### Building from Source (For Developers) +It also adds container management commands: -If you've cloned the repository and want to build from source, use `docker-compose.local.yml`: +| Command | Description | +| --- | --- | +| `llms up [port]` | Start the server in the background (`--restart unless-stopped`) | +| `llms down` | Stop and remove the server | +| `llms restart` | Restart the server | +| `llms status` | Show whether the server is running, and its health | +| `llms logs [-f]` | Show server logs | +| `llms setup` | Re-open the provider picker | +| `llms update` | Pull the latest image and restart if running | +| `llms shell` | Open a shell inside the container | +| `llms uninstall` | Remove the command and container | -```bash -# Build and start services -docker-compose -f docker-compose.local.yml up -d --build +`llms --serve [port]` is accepted as an alias for `llms up` so copy-pasted docs +work. -# View logs -docker-compose -f docker-compose.local.yml logs -f +### Notes on the wrapper -# Stop services -docker-compose -f docker-compose.local.yml down +- **Current directory** is mounted at `/work` and set as the working directory, + so relative paths in `--image`, `--audio` and `--file` work. Absolute host + paths outside the current directory won't resolve. +- **Port binding** defaults to `127.0.0.1`, so the server is not exposed to your + network. Set `LLMS_BIND=0.0.0.0` in `~/.llms/config` to change that. +- **Host services** are reachable at `host.docker.internal` (e.g. Ollama at + `http://host.docker.internal:11434`). -# Rebuild and restart -docker-compose -f docker-compose.local.yml up -d --build -``` - -## Data Persistence - -The container stores configuration and analytics data in `/home/llms/.llms`. - -On first run, the container will automatically create default `llms.json` and `providers.json` files in this directory. - -### Named Volume (Recommended) +Every setting can be overridden per-command or edited in `~/.llms/config`: ```bash -docker run -p 8000:8000 \ - -v llms-data:/home/llms/.llms \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest +LLMS_PORT=3000 llms up +LLMS_IMAGE=ghcr.io/servicestack/llms:v4.0.10 llms ls ``` -### Local Directory +## Configuration -```bash -docker run -p 8000:8000 \ - -v $(pwd)/llms-config:/home/llms/.llms \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest -``` +Everything lives in `~/.llms`, which is bind-mounted into the container at +`/home/llms/.llms`: -## Custom Configuration Files +| File | Purpose | +| --- | --- | +| `llms.json` | Providers, models, defaults — edit freely | +| `providers.json` | Provider/model catalogue from models.dev | +| `providers-extra.json` | Extra providers and models | +| `.env` | API keys, one `VAR=value` per line, mode `600` | +| `config` | Settings for the `llms` command (image, port, bind address) | +| `docker-compose.yml` | Optional, generated — an alternative to `llms up` | -You can customize the behavior of llms-py by providing your own `llms.json` and `providers.json` configuration files. +Because the container reads these from the mount, editing `llms.json` on the host +takes effect on the next `llms restart`. -### Method 1: Mount a Local Directory (Recommended) +### API keys -1. Create a local directory with your custom config files: +Keys are read from `~/.llms/.env` and passed to the container with +`--env-file`. That file's format is strict: ```bash -# Option A: Use the provided extraction script (easiest) -./docker-extract-configs.sh config - -# Option B: Manual extraction -mkdir -p config -docker run --rm -v $(pwd)/config:/home/llms/.llms \ - ghcr.io/servicestack/llms:latest \ - llms --init +GROQ_API_KEY=gsk_... +OPENAI_API_KEY=sk-... ``` -2. Edit `config/llms.json` and `config/providers.json` to your preferences - -3. Mount the directory when running the container: - -```bash -docker run -p 8000:8000 \ - -v $(pwd)/config:/home/llms/.llms \ - -e OPENROUTER_API_KEY="your-key" \ - ghcr.io/servicestack/llms:latest -``` +No quotes, no spaces around `=`, no `export`. `llms setup` writes it correctly; +if you edit it by hand, keep to that format. -Or with docker-compose, update the volumes section: +Which env var each provider uses comes from `providers.json` — see +[`.env.example`](.env.example) for the full list. -```yaml -volumes: - - ./config:/home/llms/.llms -``` +## Running without the installer -### Method 2: Mount Individual Config Files +Nothing above is required; the image works standalone. -Mount specific config files (read-only recommended to prevent accidental changes): +### docker run ```bash -docker run -p 8000:8000 \ - -v $(pwd)/my-llms.json:/home/llms/.llms/llms.json:ro \ - -v $(pwd)/my-providers.json:/home/llms/.llms/providers.json:ro \ - -v $(pwd)/my-providers-extra.json:/home/llms/.llms/providers-extra.json:ro \ - -e OPENROUTER_API_KEY="your-key" \ +docker pull ghcr.io/servicestack/llms:latest + +docker run -d --name llms \ + -p 127.0.0.1:8000:8000 \ + -v ~/.llms:/home/llms/.llms \ + --add-host=host.docker.internal:host-gateway \ + -e OPENROUTER_API_KEY="sk-or-..." \ ghcr.io/servicestack/llms:latest ``` -Or with docker-compose: +One-shot CLI use: -```yaml -volumes: - - ./my-llms.json:/home/llms/.llms/llms.json:ro - - ./my-providers.json:/home/llms/.llms/providers.json:ro - - ./my-providers-extra.json:/home/llms/.llms/providers-extra.json:ro +```bash +docker run --rm -v ~/.llms:/home/llms/.llms \ + -e GROQ_API_KEY="gsk_..." \ + --entrypoint llms ghcr.io/servicestack/llms:latest ls ``` -### Method 3: Initialize and Extract Configs - -1. Run the container with a named volume to initialize default configs: +### docker compose ```bash -docker run --rm \ - -v llms-data:/home/llms/.llms \ - ghcr.io/servicestack/llms:latest \ - llms --init +cp .env.example .env # then fill in your keys +docker compose up -d +docker compose logs -f +docker compose down ``` -2. Extract the configs to customize them: +The bundled `docker-compose.yml` uses `env_file: .env`, so it picks up every key +in that file without needing an entry per provider. -```bash -# Create a temporary container to copy files -docker run -d --name llms-temp -v llms-data:/home/llms/.llms ghcr.io/servicestack/llms:latest sleep 60 -docker cp llms-temp:/home/llms/.llms/llms.json ./llms.json -docker cp llms-temp:/home/llms/.llms/providers.json ./providers.json -docker cp llms-temp:/home/llms/.llms/providers-extra.json ./providers-extra.json -docker rm -f llms-temp -``` +### Available tags -3. Edit the files and copy them back: +Published to GitHub Container Registry and Docker Hub on every push to `main` +and every `v*` tag, for `linux/amd64` and `linux/arm64`: -```bash -# After editing, copy back -docker run -d --name llms-temp -v llms-data:/home/llms/.llms ghcr.io/servicestack/llms:latest sleep 60 -docker cp ./llms.json llms-temp:/home/llms/.llms/llms.json -docker cp ./providers.json llms-temp:/home/llms/.llms/providers.json -docker cp ./providers-extra.json llms-temp:/home/llms/.llms/providers-extra.json -docker rm -f llms-temp -``` +| Tag | Description | +| --- | --- | +| `ghcr.io/servicestack/llms:latest` | Latest release | +| `ghcr.io/servicestack/llms:4.0.10` | A specific version | +| `ghcr.io/servicestack/llms:4.0` | Latest 4.0.x | +| `ghcr.io/servicestack/llms:main` | Latest `main` build | -### What Can You Customize? - -**In `llms.json`:** -- Enable/disable providers -- Add or remove models -- Configure API endpoints -- Set pricing information -- Customize default chat templates -- Configure provider-specific settings - -**In `providers-extra.json`:** -- additional list of providers and models - -### Example: Custom Provider Configuration - -Create a custom `llms.json` with only the providers you want: - -```json -{ - "defaults": { - "headers": { - "Content-Type": "application/json" - }, - "text": { - "model": "llama3.3:70b", - "messages": [ - { - "role": "user", - "content": "" - } - ] - } - }, - "providers": { - "groq": { - "enabled": true, - } - } -} -``` +### Custom config files -Then mount it: +Mount individual files read-only to pin them: ```bash docker run -p 8000:8000 \ - -v $(pwd)/custom-llms.json:/home/llms/.llms/llms.json:ro \ - -e GROQ_API_KEY="your-key" \ + -v $(pwd)/my-llms.json:/home/llms/.llms/llms.json:ro \ + -v $(pwd)/my-providers-extra.json:/home/llms/.llms/providers-extra.json:ro \ ghcr.io/servicestack/llms:latest ``` -## Health Checks - -The Docker image includes a health check that verifies the server is responding. - -### Check Container Health +Or extract the defaults first, edit, then mount the whole directory: ```bash -docker ps +./docker-extract-configs.sh config +# edit config/llms.json +docker run -p 8000:8000 -v $(pwd)/config:/home/llms/.llms ghcr.io/servicestack/llms:latest ``` -Look for the health status in the STATUS column. - -### View Health Check Details +## Building and testing locally ```bash -docker inspect --format='{{json .State.Health}}' llms-server | jq -``` - -## Multi-Architecture Support - -The published Docker images support: -- `linux/amd64` (Intel/AMD x86_64) -- `linux/arm64` (ARM64/Apple Silicon) - -Docker automatically pulls the correct image for your platform. +./docker-build.sh # builds llms-py:latest +./docker-build.sh v4.0.10 # with a tag -## GitHub Container Registry +docker compose -f docker-compose.local.yml up -d --build +``` -Images are automatically published to GitHub Container Registry on: -- Push to main branch → `ghcr.io/servicestack/llms:main` -- Tagged releases → `ghcr.io/servicestack/llms:v2.0.24` -- Latest tag → `ghcr.io/servicestack/llms:latest` +### Testing an image -### Pull Specific Version +`scripts/test-docker.sh` pulls the image and exercises it end to end — metadata, +multi-arch manifest, non-root user, toolchain versions, `llms --init`, +`llms ls`, a live HTTP server, and Docker's own health check: ```bash -docker pull ghcr.io/servicestack/llms:v2.0.24 +./scripts/test-docker.sh # test the published latest +./scripts/test-docker.sh --image llms-py:dev # test a local build +./scripts/test-docker.sh --no-pull # skip the pull +./scripts/test-docker.sh --quick # skip the server tests +./scripts/test-docker.sh --keep # leave the container running ``` -### Pull Latest - -```bash -docker pull ghcr.io/servicestack/llms:latest -``` +It exits non-zero if any check fails, so it works as a release gate. If a +provider API key happens to be in your environment it also runs a live +`llms --check` against that provider. ## Troubleshooting -### Container Won't Start +**`docker: command not found` / daemon not running** +The installer prints the right command for your platform. On macOS you need +Docker Desktop actually launched, not just installed. -Check logs: -```bash -docker logs llms-server -``` +**Permission denied writing to `~/.llms` (Linux)** +The image runs as UID 1000. If your UID is different, the installer detects it +and adds `--user $(id -u):$(id -g)` — stored as `LLMS_DOCKER_USER_ARGS` in +`~/.llms/config`. If you're running Docker by hand, add that flag yourself. -### Permission Issues +**A provider is enabled but its models don't appear** +Check the key is actually reaching the container: -The container runs as user `llms` (UID 1000). If mounting local directories, ensure they're writable: ```bash -mkdir -p llms-config -chmod 777 llms-config +llms setup --list # shows every provider, its env var, and where its key came from +llms --check groq # asks the provider directly ``` -### Port Already in Use +**Ollama / LM Studio on the host aren't reachable** +Inside a container `localhost` is the container. Use +`http://host.docker.internal:11434` — `llms setup` rewrites this for you when you +enable a local provider. + +**Port already in use** -Change the host port: ```bash -docker run -p 3000:8000 ... +llms down +LLMS_PORT=3000 llms up # or set LLMS_PORT in ~/.llms/config ``` -### API Keys Not Working +**Start over** -Verify environment variables are set: ```bash -docker exec llms-server env | grep API_KEY +llms down +rm -rf ~/.llms +curl -fsSL https://llmspy.org/install.sh | bash ``` -## Security Considerations - -- Container runs as non-root user (UID 1000) -- Only port 8000 is exposed -- No unnecessary packages installed -- Multi-stage build reduces attack surface -- Health checks ensure service availability - -## Performance - -- Multi-stage build keeps final image small -- Layer caching speeds up rebuilds -- aiohttp provides async performance -- Health checks prevent routing to unhealthy containers +## Security notes +- The container runs as non-root (UID 1000) and only exposes port 8000. +- The server binds to `127.0.0.1` by default — it is not on your network unless + you set `LLMS_BIND`. +- `~/.llms/.env` is written mode `600`. +- Images are published with build provenance attestations. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6253f9b..51d8886 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -6,35 +6,30 @@ services: image: llms-py:latest container_name: llms-py ports: - - "8000:8000" + - "127.0.0.1:8000:8000" + env_file: + # All API keys live here, one VAR=value per line, no quotes. + # Create it with `cp .env.example .env`. + - .env environment: - # API Keys - Set these in your .env file or pass them directly - - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - - GROQ_API_KEY=${GROQ_API_KEY:-} - - GOOGLE_FREE_API_KEY=${GOOGLE_FREE_API_KEY:-} - - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - - CODESTRAL_API_KEY=${CODESTRAL_API_KEY:-} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - OPENAI_API_KEY=${OPENAI_API_KEY:-} - - GROK_API_KEY=${GROK_API_KEY:-} - - DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY:-} - - ZAI_API_KEY=${ZAI_API_KEY:-} - - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} + - VERBOSE=1 volumes: - # Persist configuration and analytics data - # The container will auto-create llms.json and providers.json on first run + # Persist configuration and analytics data. + # llms.json and providers.json are auto-created on first run. - llms-data:/home/llms/.llms - # Alternative: Mount a local directory to use custom config files - # Uncomment the line below and comment out the line above to use local configs - # Place your custom llms.json and providers.json in ./config/ directory + # Alternative: mount a local directory to use custom config files. + # Comment out the line above and uncomment this one, then put your + # llms.json / providers.json / providers-extra.json in ./config/ # - ./config:/home/llms/.llms + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000').read()" ] interval: 30s timeout: 10s retries: 3 - start_period: 5s + start_period: 10s volumes: llms-data: diff --git a/docker-compose.yml b/docker-compose.yml index a69c147..bc1baa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,29 +3,26 @@ services: image: ghcr.io/servicestack/llms:latest container_name: llms-py ports: - - "8000:8000" - environment: - # API Keys - Set these in your .env file or pass them directly - - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - - GROQ_API_KEY=${GROQ_API_KEY:-} - - GOOGLE_FREE_API_KEY=${GOOGLE_FREE_API_KEY:-} - - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - - CODESTRAL_API_KEY=${CODESTRAL_API_KEY:-} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - OPENAI_API_KEY=${OPENAI_API_KEY:-} - - GROK_API_KEY=${GROK_API_KEY:-} - - DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY:-} - - ZAI_API_KEY=${ZAI_API_KEY:-} - - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} - - VERBOSE=1 + # Only reachable from this machine. Use "8000:8000" to expose it on your LAN. + - "127.0.0.1:8000:8000" + env_file: + # All API keys live here, one VAR=value per line, no quotes. + # Create it with `cp .env.example .env`, or run: + # curl -fsSL https://llmspy.org/install.sh | bash + # which generates ~/.llms/.env for you. + - .env volumes: - # Maintain user-modifiable config files outside the container - # llms.json and providers.json will be auto-created on first run + # User-modifiable config kept outside the container. + # llms.json and providers.json are auto-created on first run. - ~/.llms:/home/llms/.llms + extra_hosts: + # Lets the container reach services on the host (ollama, LM Studio, ...) + # via http://host.docker.internal:11434 etc. + - "host.docker.internal:host-gateway" restart: unless-stopped healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000').read()" ] interval: 30s timeout: 10s retries: 3 - start_period: 5s + start_period: 10s diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..fa6dc07 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,480 @@ +#!/usr/bin/env bash +# +# test-docker.sh — smoke test the published llms-py Docker image. +# +# Pulls the latest image (unless --no-pull) and exercises it end to end: +# image metadata, multi-arch manifest, toolchain, CLI, config init, and a +# live HTTP server with health checks. +# +# Usage: +# ./scripts/test-docker.sh # test ghcr.io/servicestack/llms:latest +# ./scripts/test-docker.sh --image llms-py:dev # test a locally built image +# ./scripts/test-docker.sh --no-pull # skip docker pull +# ./scripts/test-docker.sh --quick # skip the server tests +# ./scripts/test-docker.sh --keep # don't remove the test container +# +# Exit code is 0 only if every test passed. + +set -uo pipefail + +IMAGE="${LLMS_DOCKER_IMAGE:-ghcr.io/servicestack/llms:latest}" +PORT="" +DO_PULL=1 +DO_SERVER=1 +DO_KEEP=0 +CONTAINER="llms-test-$$" +WORKDIR="" + +while [ $# -gt 0 ]; do + case "$1" in + --image) IMAGE="$2"; shift 2 ;; + --image=*) IMAGE="${1#*=}"; shift ;; + --port) PORT="$2"; shift 2 ;; + --port=*) PORT="${1#*=}"; shift ;; + --no-pull) DO_PULL=0; shift ;; + --quick) DO_SERVER=0; shift ;; + --keep) DO_KEEP=1; shift ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done + +# ---------------------------------------------------------------- output ---- + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + B=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GRN=$'\033[32m' + YEL=$'\033[33m'; CYA=$'\033[36m'; N=$'\033[0m' +else + B=""; DIM=""; RED=""; GRN=""; YEL=""; CYA=""; N="" +fi + +PASSED=0; FAILED=0; SKIPPED=0 +FAILURES=() + +section() { printf '\n%s%s%s\n' "$B$CYA" "$1" "$N"; } +pass() { PASSED=$((PASSED+1)); printf ' %s✓%s %s\n' "$GRN" "$N" "$1"; } +fail() { FAILED=$((FAILED+1)); FAILURES+=("$1"); printf ' %s✗%s %s\n' "$RED" "$N" "$1" + [ $# -gt 1 ] && printf ' %s%s%s\n' "$DIM" "$2" "$N"; return 0; } +skip() { SKIPPED=$((SKIPPED+1)); printf ' %s–%s %s %s(%s)%s\n' "$YEL" "$N" "$1" "$DIM" "${2:-skipped}" "$N"; } +info() { printf ' %s%s%s\n' "$DIM" "$1" "$N"; } + +# Run a check: check "" ; stdout/stderr captured, shown on failure. +check() { + local name="$1"; shift + local out rc + out=$("$@" 2>&1); rc=$? + if [ $rc -eq 0 ]; then + pass "$name" + LAST_OUT="$out"; export LAST_OUT + return 0 + fi + fail "$name" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ')" + LAST_OUT="$out" + return 1 +} + +cleanup() { + if [ "$DO_KEEP" -eq 1 ]; then + [ -n "$WORKDIR" ] && info "kept workdir: $WORKDIR" + docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER" \ + && info "kept container: $CONTAINER (docker rm -f $CONTAINER)" + return + fi + docker rm -f "$CONTAINER" >/dev/null 2>&1 + [ -n "$WORKDIR" ] && [ -d "$WORKDIR" ] && rm -rf "$WORKDIR" + return 0 +} +trap cleanup EXIT INT TERM + +# Run llms in a throwaway container. Args are passed to the llms CLI. +run_llms() { docker run --rm --entrypoint llms "$IMAGE" "$@"; } + +# Run an arbitrary command in a throwaway container. +run_in() { local cmd="$1"; shift; docker run --rm --entrypoint "$cmd" "$IMAGE" "$@"; } + +printf '%sllms-py Docker image test%s\n' "$B" "$N" +printf '%simage: %s%s\n' "$DIM" "$IMAGE" "$N" + +# --------------------------------------------------------- prerequisites ---- + +section "Prerequisites" + +if ! command -v docker >/dev/null 2>&1; then + fail "docker is installed" "docker not found on PATH" + printf '\n%sInstall Docker: https://docs.docker.com/get-docker/%s\n' "$RED" "$N" + exit 1 +fi +pass "docker is installed ($(docker --version 2>/dev/null | head -1))" + +if ! docker info >/dev/null 2>&1; then + fail "docker daemon is running" "cannot connect to the Docker daemon" + printf '\n%sStart Docker Desktop (or the docker service) and re-run.%s\n' "$RED" "$N" + exit 1 +fi +pass "docker daemon is running" + +if docker compose version >/dev/null 2>&1; then + pass "docker compose v2 available ($(docker compose version --short 2>/dev/null))" +elif command -v docker-compose >/dev/null 2>&1; then + skip "docker compose v2 available" "only legacy docker-compose found" +else + skip "docker compose v2 available" "not installed" +fi + +if command -v curl >/dev/null 2>&1; then + pass "curl is installed" +else + fail "curl is installed" "needed for the server health tests" + DO_SERVER=0 +fi + +# ------------------------------------------------------------------ pull ---- + +section "Image" + +DIGEST_BEFORE=$(docker image inspect --format '{{index .RepoDigests 0}}' "$IMAGE" 2>/dev/null) + +if [ "$DO_PULL" -eq 1 ]; then + if docker pull "$IMAGE" >/dev/null 2>&1; then + pass "docker pull $IMAGE" + else + fail "docker pull $IMAGE" "$(docker pull "$IMAGE" 2>&1 | tail -2 | tr '\n' ' ')" + printf '\n%sCould not pull the image — aborting.%s\n' "$RED" "$N" + exit 1 + fi +else + skip "docker pull $IMAGE" "--no-pull" +fi + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + fail "image present locally" "run without --no-pull, or build it first" + exit 1 +fi +pass "image present locally" + +DIGEST_AFTER=$(docker image inspect --format '{{index .RepoDigests 0}}' "$IMAGE" 2>/dev/null) +if [ -n "$DIGEST_AFTER" ]; then + info "digest: ${DIGEST_AFTER#*@}" + if [ "$DO_PULL" -eq 1 ] && [ -n "$DIGEST_BEFORE" ] && [ "$DIGEST_BEFORE" != "$DIGEST_AFTER" ]; then + info "updated: was ${DIGEST_BEFORE#*@}" + elif [ "$DO_PULL" -eq 1 ] && [ -n "$DIGEST_BEFORE" ]; then + info "already up to date" + fi +fi + +IMG_CREATED=$(docker image inspect --format '{{.Created}}' "$IMAGE" 2>/dev/null) +IMG_SIZE=$(docker image inspect --format '{{.Size}}' "$IMAGE" 2>/dev/null) +IMG_ARCH=$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMAGE" 2>/dev/null) +info "created: ${IMG_CREATED%.*}" +info "size: $(( ${IMG_SIZE:-0} / 1024 / 1024 )) MB" +info "arch: $IMG_ARCH" + +HOST_ARCH=$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null) +if [ -n "$HOST_ARCH" ] && [ "$IMG_ARCH" = "$HOST_ARCH" ]; then + pass "image architecture matches host ($IMG_ARCH)" +elif [ -n "$HOST_ARCH" ]; then + skip "image architecture matches host" "image $IMG_ARCH, host $HOST_ARCH — will run emulated" +fi + +# Multi-arch manifest (only meaningful for a registry image) +case "$IMAGE" in + *ghcr.io/*|*docker.io/*|*/*:*) + PLATFORMS=$(docker buildx imagetools inspect "$IMAGE" 2>/dev/null \ + | awk '/^ *Platform:/ {print $2}' | sort -u | tr '\n' ' ') + if [ -n "$PLATFORMS" ]; then + pass "multi-arch manifest published" + info "platforms: $PLATFORMS" + case "$PLATFORMS" in + *linux/amd64*) : ;; + *) fail "manifest includes linux/amd64" "got: $PLATFORMS" ;; + esac + case "$PLATFORMS" in + *linux/arm64*) : ;; + *) fail "manifest includes linux/arm64" "got: $PLATFORMS" ;; + esac + else + skip "multi-arch manifest published" "buildx imagetools unavailable or local image" + fi + ;; + *) skip "multi-arch manifest published" "local image" ;; +esac + +# ------------------------------------------------------------ image spec ---- + +section "Image configuration" + +IMG_USER=$(docker image inspect --format '{{.Config.User}}' "$IMAGE" 2>/dev/null) +if [ "$IMG_USER" = "llms" ] || [ "$IMG_USER" = "1000" ]; then + pass "runs as non-root user ($IMG_USER)" +else + fail "runs as non-root user" "Config.User='$IMG_USER'" +fi + +UID_IN=$(run_in id -u 2>/dev/null | tr -d '\r') +if [ "$UID_IN" = "1000" ]; then + pass "container uid is 1000" +else + fail "container uid is 1000" "got '$UID_IN'" +fi + +if docker image inspect --format '{{json .Config.ExposedPorts}}' "$IMAGE" 2>/dev/null | grep -q '8000/tcp'; then + pass "exposes port 8000" +else + fail "exposes port 8000" +fi + +if docker image inspect --format '{{json .Config.Healthcheck}}' "$IMAGE" 2>/dev/null | grep -q 'urllib'; then + pass "HEALTHCHECK is defined" +else + fail "HEALTHCHECK is defined" +fi + +IMG_CMD=$(docker image inspect --format '{{json .Config.Cmd}}' "$IMAGE" 2>/dev/null) +if printf '%s' "$IMG_CMD" | grep -q -- '--serve'; then + pass "default CMD starts the server ($IMG_CMD)" +else + fail "default CMD starts the server" "Cmd=$IMG_CMD" +fi + +if docker image inspect --format '{{json .Config.Volumes}}' "$IMAGE" 2>/dev/null | grep -q '/home/llms/.llms'; then + pass "declares /home/llms/.llms volume" +else + fail "declares /home/llms/.llms volume" +fi + +# -------------------------------------------------------------- toolchain --- + +section "Toolchain" + +if VER=$(run_in python -c "from importlib.metadata import version; print(version('llms-py'))" 2>/dev/null | tr -d '\r'); then + if [ -n "$VER" ]; then + pass "llms-py installed (v$VER)" + else + fail "llms-py installed" "version query returned nothing" + fi +else + fail "llms-py installed" "importlib.metadata could not find llms-py" +fi + +if OUT=$(run_in python --version 2>&1 | tr -d '\r'); then + pass "python present ($OUT)" +else + fail "python present" +fi + +if OUT=$(run_in bun --version 2>&1 | tr -d '\r'); then + pass "bun present (v$OUT)" +else + fail "bun present" "$OUT" +fi + +if OUT=$(run_in bunx --version 2>&1 | tr -d '\r'); then + pass "bunx present (v$OUT)" +else + fail "bunx present" "$OUT" +fi + +if OUT=$(docker run --rm --entrypoint sh "$IMAGE" -c 'dotnet --version' 2>&1 | tr -d '\r'); then + pass "dotnet sdk present (v$OUT)" +else + fail "dotnet sdk present" "$OUT" +fi + +if run_in sh -c 'command -v llms' >/dev/null 2>&1; then + pass "llms is on PATH" +else + fail "llms is on PATH" +fi + +if run_in sh -c 'command -v git' >/dev/null 2>&1; then + pass "git present" +else + fail "git present" +fi + +# -------------------------------------------------------------------- CLI --- + +section "CLI" + +if OUT=$(run_llms --help 2>&1); then + pass "llms --help" + HELP_VER=$(printf '%s' "$OUT" | grep -o 'llms v[0-9][0-9.]*' | head -1) + [ -n "$HELP_VER" ] && info "reports: $HELP_VER" +else + fail "llms --help" "$(printf '%s' "$OUT" | tail -3 | tr '\n' ' ')" +fi + +WORKDIR=$(mktemp -d 2>/dev/null || mktemp -d -t llms-test) +mkdir -p "$WORKDIR/config" +chmod 777 "$WORKDIR/config" + +if OUT=$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" --entrypoint llms "$IMAGE" --init 2>&1); then + pass "llms --init" +else + fail "llms --init" "$(printf '%s' "$OUT" | tail -3 | tr '\n' ' ')" +fi + +for f in llms.json providers.json; do + if [ -s "$WORKDIR/config/$f" ]; then + if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$WORKDIR/config/$f" 2>/dev/null; then + pass "$f created and is valid JSON ($(wc -c < "$WORKDIR/config/$f" | tr -d ' ') bytes)" + else + fail "$f created and is valid JSON" "file exists but does not parse" + fi + else + fail "$f created" "not found in the mounted config dir" + fi +done + +if [ -s "$WORKDIR/config/providers-extra.json" ]; then + pass "providers-extra.json created" +else + skip "providers-extra.json created" "not written by --init" +fi + +# Provider catalogue sanity — the installer's TUI reads this same file. +if [ -s "$WORKDIR/config/providers.json" ]; then + NPROV=$(python3 -c " +import json,sys +d=json.load(open(sys.argv[1])) +print(sum(1 for v in d.values() if isinstance(v,dict) and v.get('env'))) +" "$WORKDIR/config/providers.json" 2>/dev/null) + if [ -n "$NPROV" ] && [ "$NPROV" -gt 5 ] 2>/dev/null; then + pass "providers.json lists $NPROV providers with API key env vars" + else + fail "providers.json lists providers with API key env vars" "found '$NPROV'" + fi +fi + +if OUT=$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" --entrypoint llms "$IMAGE" ls 2>&1); then + pass "llms ls" + ENABLED=$(printf '%s' "$OUT" | grep -o 'enabled providers:.*' | head -1) + [ -n "$ENABLED" ] && info "$ENABLED" +else + fail "llms ls" "$(printf '%s' "$OUT" | tail -3 | tr '\n' ' ')" +fi + +# Optional live provider check when a key happens to be in the environment. +LIVE_PROVIDER=""; LIVE_KEY="" +for pair in "groq:GROQ_API_KEY" "openrouter:OPENROUTER_API_KEY" "google:GOOGLE_API_KEY" \ + "anthropic:ANTHROPIC_API_KEY" "openai:OPENAI_API_KEY"; do + p="${pair%%:*}"; k="${pair#*:}" + if [ -n "${!k:-}" ]; then LIVE_PROVIDER="$p"; LIVE_KEY="$k"; break; fi +done +if [ -n "$LIVE_PROVIDER" ]; then + if OUT=$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" -e "$LIVE_KEY=${!LIVE_KEY}" \ + --entrypoint llms "$IMAGE" --check "$LIVE_PROVIDER" 2>&1); then + pass "llms --check $LIVE_PROVIDER (live, using \$$LIVE_KEY)" + else + fail "llms --check $LIVE_PROVIDER (live)" "$(printf '%s' "$OUT" | tail -3 | tr '\n' ' ')" + fi +else + skip "live provider check" "no provider API key in the environment" +fi + +# ----------------------------------------------------------------- server --- + +if [ "$DO_SERVER" -eq 0 ]; then + section "Server" + skip "server tests" "--quick" +else + section "Server" + + if [ -z "$PORT" ]; then + for p in $(seq 18000 18050); do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi + exec 3>&- 2>/dev/null + done + fi + if [ -z "$PORT" ]; then + fail "find a free port" "18000-18050 all in use; pass --port" + else + info "using port $PORT" + + if docker run -d --name "$CONTAINER" -p "127.0.0.1:$PORT:8000" \ + -v "$WORKDIR/config:/home/llms/.llms" "$IMAGE" >/dev/null 2>&1; then + pass "container starts" + else + fail "container starts" "$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" "$IMAGE" 2>&1 | tail -3 | tr '\n' ' ')" + fi + + # Wait for HTTP + HTTP_OK=0 + for _ in $(seq 1 60); do + CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:$PORT/" 2>/dev/null) + if [ "$CODE" = "200" ]; then HTTP_OK=1; break; fi + docker ps --format '{{.Names}}' | grep -qx "$CONTAINER" || break + sleep 1 + done + if [ "$HTTP_OK" -eq 1 ]; then + pass "GET / returns 200" + else + fail "GET / returns 200" "last status '${CODE:-none}' after 60s" + docker logs "$CONTAINER" 2>&1 | tail -15 | sed "s/^/ ${DIM}/;s/\$/${N}/" + fi + + if [ "$HTTP_OK" -eq 1 ]; then + BODY=$(curl -s --max-time 5 "http://127.0.0.1:$PORT/" 2>/dev/null) + if printf '%s' "$BODY" | grep -qi '/dev/null) + if [ "$CODE" = "200" ] || [ "$CODE" = "401" ]; then + pass "/v1/models responds ($CODE)" + else + fail "/v1/models responds" "status $CODE" + fi + + # Docker's own HEALTHCHECK + HEALTH="" + for _ in $(seq 1 45); do + HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$CONTAINER" 2>/dev/null) + [ "$HEALTH" = "healthy" ] && break + [ "$HEALTH" = "unhealthy" ] && break + sleep 1 + done + if [ "$HEALTH" = "healthy" ]; then + pass "docker HEALTHCHECK reports healthy" + elif [ -z "$HEALTH" ]; then + skip "docker HEALTHCHECK reports healthy" "no health state" + else + fail "docker HEALTHCHECK reports healthy" "status '$HEALTH'" + fi + fi + + LOGS=$(docker logs "$CONTAINER" 2>&1) + if printf '%s' "$LOGS" | grep -q 'Traceback (most recent call last)'; then + fail "startup logs are free of tracebacks" "$(printf '%s' "$LOGS" | grep -A2 'Traceback' | head -3 | tr '\n' ' ')" + else + pass "startup logs are free of tracebacks" + fi + + if docker stop -t 10 "$CONTAINER" >/dev/null 2>&1; then + pass "container stops cleanly" + else + fail "container stops cleanly" + fi + [ "$DO_KEEP" -eq 0 ] && docker rm -f "$CONTAINER" >/dev/null 2>&1 + fi +fi + +# ---------------------------------------------------------------- summary --- + +section "Summary" +printf ' %s%d passed%s' "$GRN" "$PASSED" "$N" +[ "$FAILED" -gt 0 ] && printf ', %s%d failed%s' "$RED" "$FAILED" "$N" +[ "$SKIPPED" -gt 0 ] && printf ', %s%d skipped%s' "$YEL" "$SKIPPED" "$N" +printf '\n' + +if [ "$FAILED" -gt 0 ]; then + printf '\n%sFailed:%s\n' "$B$RED" "$N" + for f in "${FAILURES[@]}"; do printf ' • %s\n' "$f"; done + printf '\n' + exit 1 +fi + +printf '\n%sImage looks good.%s\n' "$GRN" "$N" +exit 0