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