Skip to content

Commit a8ca524

Browse files
committed
update Dockerfile
1 parent 1a0b1fa commit a8ca524

3 files changed

Lines changed: 288 additions & 4 deletions

File tree

DOCKER.md

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Running llms-py in Docker
22

33
The Docker image bundles llms.py with everything its extensions need — Python,
4-
`bun`, and the .NET SDK — so nothing has to be installed on the host beyond
5-
Docker itself.
4+
`bun`, the .NET SDK and `typst` — so nothing has to be installed on the host
5+
beyond Docker itself.
66

77
- [Quick start](#quick-start) — the one-line installer
88
- [What the installer does](#what-the-installer-does)
@@ -290,6 +290,137 @@ It exits non-zero if any check fails, so it works as a release gate. If a
290290
provider API key happens to be in your environment it also runs a live
291291
`llms --check` against that provider.
292292

293+
## What's in the image
294+
295+
| Tool | Used for |
296+
| --- | --- |
297+
| Python 3.11 + llms-py | the CLI and server |
298+
| `bun` / `bunx` | JavaScript extensions and tools |
299+
| .NET SDK 10 | running C# code |
300+
| `typst` | the PDF Studio extension (`.typ` templates → PDF) |
301+
| `ffmpeg` | audio conversion for voice input |
302+
| `git` | installing extensions from a repo |
303+
304+
Each is verified at build time, so a broken image fails to build rather than
305+
failing on your first request.
306+
307+
## Voice input
308+
309+
The microphone lives in the browser, not the container — the chat UI records
310+
audio and POSTs it to `/transcribe`, so no device passthrough is needed. The
311+
container's only job is turning that audio into text.
312+
313+
The voice extension's `api` mode needs nothing installed, so it works in the
314+
container out of the box. Add a key and restart:
315+
316+
```bash
317+
echo 'GROQ_API_KEY=gsk_...' >> ~/.llms/.env
318+
llms restart
319+
```
320+
321+
Configure the provider and model under `defaults` in `~/.llms/llms.json`:
322+
323+
```json
324+
{
325+
"defaults": {
326+
"voice": {
327+
"provider": "groq",
328+
"model": "whisper-large-v3",
329+
"language": "en"
330+
}
331+
}
332+
}
333+
```
334+
335+
Or point it at a local speech-to-text server — no key required. From inside the
336+
container the host is `host.docker.internal`:
337+
338+
```json
339+
{
340+
"defaults": {
341+
"voice": {
342+
"url": "http://host.docker.internal:8001/v1/audio/transcriptions",
343+
"model": "Systran/faster-whisper-small"
344+
}
345+
}
346+
}
347+
```
348+
349+
This is the same configuration llms.py uses everywhere, not something
350+
Docker-specific — see [Voice Input](https://llmspy.org/docs/features/voice-input)
351+
for every setting, the `LLMS_TRANSCRIBE_*` environment overrides, and the other
352+
modes.
353+
354+
Check which mode was selected:
355+
356+
```bash
357+
llms restart --verbose && llms logs | grep -i voice
358+
```
359+
360+
```
361+
Using api for voice: groq [llms.json] model=whisper-large-v3 [llms.json]
362+
```
363+
364+
The image also ships `ffmpeg`, which the `voxtype` and `transcribe` modes need
365+
to convert the browser's webm recording. `voxtype` requires a graphical desktop
366+
session so it never applies in a container; `transcribe` is available if you
367+
mount your own script at `/usr/local/bin/transcribe`.
368+
369+
### The microphone button is missing
370+
371+
Browsers only expose `getUserMedia` in a **secure context**: HTTPS, or
372+
`http://localhost` / `http://127.0.0.1`. The default `llms up` binds to
373+
`127.0.0.1`, so it works.
374+
375+
If you set `LLMS_BIND=0.0.0.0` and browse to `http://192.168.x.x:8000`, the
376+
browser silently withholds the microphone API and no button appears — nothing to
377+
do with Docker or your configuration. Reach it over an SSH tunnel
378+
(`ssh -L 8000:localhost:8000 host`) or put it behind a TLS-terminating reverse
379+
proxy.
380+
381+
### Logging
382+
383+
`llms` reads two environment variables, both off by default:
384+
385+
| Variable | Effect |
386+
| --- | --- |
387+
| `VERBOSE=1` | request/response logging (same as `--verbose`) |
388+
| `DEBUG=1` | debug logging |
389+
390+
The `llms` command turns them into container env vars for you:
391+
392+
```bash
393+
llms up --verbose # request logging
394+
llms up --debug # verbose + debug
395+
llms up --debug -f # ...and follow the logs
396+
llms restart --debug # turn it on for a running server
397+
llms --debug ls # one-shot commands too
398+
llms logs # last 200 lines
399+
llms logs -f # follow
400+
llms status # shows the active log level
401+
```
402+
403+
To make it permanent, set `LLMS_VERBOSE=1` or `LLMS_DEBUG=1` in `~/.llms/config`.
404+
405+
Running the image directly, pass them as normal env vars:
406+
407+
```bash
408+
docker run -e VERBOSE=1 -e DEBUG=1 -p 8000:8000 \
409+
-v ~/.llms:/home/llms/.llms ghcr.io/servicestack/llms:latest
410+
```
411+
412+
### Passing your own environment variables
413+
414+
Everything in `~/.llms/.env` is passed into the container, not just API keys, so
415+
it doubles as a place for any setting the container should see:
416+
417+
```bash
418+
echo 'TZ=Australia/Perth' >> ~/.llms/.env
419+
llms restart
420+
```
421+
422+
`llms setup` preserves anything there that isn't a provider API key.
423+
293424
## Troubleshooting
294425

295426
**`docker: command not found` / daemon not running**
@@ -301,6 +432,35 @@ The image runs as UID 1000. If your UID is different, the installer detects it
301432
and adds `--user $(id -u):$(id -g)` — stored as `LLMS_DOCKER_USER_ARGS` in
302433
`~/.llms/config`. If you're running Docker by hand, add that flag yourself.
303434

435+
**Voice input isn't working**
436+
Check which mode the extension picked:
437+
438+
```bash
439+
llms restart --verbose && llms logs | grep -i voice
440+
```
441+
442+
`Cannot use api - no voice provider configured` means no key and no `voice`
443+
section — add one. If no microphone button appears at all, see the secure-context
444+
note above.
445+
446+
**PDF Studio isn't available**
447+
It disables itself when `typst` isn't on PATH. Check the image has it:
448+
449+
```bash
450+
llms shell -c 'typst --version'
451+
```
452+
453+
If it's missing you're on an image built before typst was added — run
454+
`llms update`.
455+
456+
**C# code fails with "Couldn't find a valid ICU package"**
457+
An old image without `libicu`. Run `llms update`. As a stopgap you can disable
458+
globalization instead:
459+
460+
```bash
461+
echo 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1' >> ~/.llms/.env && llms restart
462+
```
463+
304464
**A provider is enabled but its models don't appear**
305465
Check the key is actually reaching the container:
306466

Dockerfile

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,29 @@ FROM python:3.11-slim
3030
# Set working directory
3131
WORKDIR /app
3232

33-
# Install system dependencies and dotnet-sdk 10.0
33+
# System dependencies.
34+
#
35+
# .NET is installed below with dotnet-install.sh rather than from the Microsoft
36+
# apt repo, so apt never pulls in its native dependencies — they have to be
37+
# listed explicitly. Without libicu every .NET process aborts on startup with
38+
# "Couldn't find a valid ICU package installed on the system".
39+
#
40+
# libicu-dev is used instead of a pinned libicuNN so this keeps working when the
41+
# Python base image moves to a newer Debian (bookworm ships libicu72, trixie
42+
# libicu76). libssl, libstdc++, zlib and libgcc already come with the base image.
43+
# ffmpeg is needed by the voice extension, which converts the browser's webm
44+
# recording to 16 kHz mono WAV before handing it to a speech-to-text tool.
45+
# See https://learn.microsoft.com/dotnet/core/install/linux-debian
3446
RUN apt-get update && apt-get install -y --no-install-recommends \
3547
wget \
3648
git \
3749
ca-certificates \
50+
xz-utils \
51+
ffmpeg \
52+
libicu-dev \
53+
libgssapi-krb5-2 \
54+
tzdata \
55+
fonts-liberation \
3856
&& rm -rf /var/lib/apt/lists/*
3957

4058
# Install dotnet-sdk 10.0 using install script to bypass GPG SHA1 issues
@@ -46,11 +64,42 @@ RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \
4664
# Set dotnet environment variables
4765
ENV DOTNET_ROOT=/usr/share/dotnet
4866
ENV PATH=$PATH:$DOTNET_ROOT
67+
ENV DOTNET_NOLOGO=1
68+
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
69+
70+
# Install typst — required by the PDF Studio extension (llms/extensions/pdf),
71+
# which disables itself when `typst` isn't on PATH.
72+
#
73+
# The upstream musl builds are statically linked, so one binary works on any
74+
# distro. TARGETARCH is supplied automatically by buildx for each platform in
75+
# the multi-arch build. Bump TYPST_VERSION to update.
76+
ARG TYPST_VERSION=0.15.1
77+
ARG TARGETARCH
78+
RUN set -eux; \
79+
case "${TARGETARCH:-amd64}" in \
80+
amd64) typst_arch=x86_64 ;; \
81+
arm64) typst_arch=aarch64 ;; \
82+
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
83+
esac; \
84+
tarball="typst-${typst_arch}-unknown-linux-musl"; \
85+
wget -qO /tmp/typst.tar.xz \
86+
"https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/${tarball}.tar.xz"; \
87+
tar -xJf /tmp/typst.tar.xz -C /tmp; \
88+
install -m 0755 "/tmp/${tarball}/typst" /usr/local/bin/typst; \
89+
rm -rf /tmp/typst.tar.xz "/tmp/${tarball}"
4990

5091
# Install bun
5192
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
5293
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
5394

95+
# Fail the build if any runtime is broken rather than at the first user request.
96+
# `dotnet --version` starts the CLI, which is itself a .NET app, so it exercises
97+
# the globalization path that needs libicu.
98+
RUN dotnet --version \
99+
&& bun --version \
100+
&& typst --version \
101+
&& ffmpeg -version > /dev/null
102+
54103
# Create a non-root user
55104
RUN useradd -m -u 1000 llms && \
56105
mkdir -p /home/llms/.llms && \
@@ -69,6 +118,13 @@ USER llms
69118
# Set home directory
70119
ENV HOME=/home/llms
71120

121+
# Don't buffer stdout/stderr, so `docker logs` shows output as it happens
122+
ENV PYTHONUNBUFFERED=1
123+
124+
# Warm the .NET first-run cache as the llms user so the first C# request
125+
# doesn't pay for it (and doesn't try to write to a read-only home).
126+
RUN dotnet --version > /dev/null 2>&1 || true
127+
72128
# Expose default port
73129
EXPOSE 8000
74130

@@ -81,5 +137,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
81137
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000').read()" || exit 1
82138

83139
# Default command - run server on port 8000
140+
# Set VERBOSE=1 and/or DEBUG=1 to get request logging (see DOCKER.md)
84141
CMD ["llms", "--serve", "8000"]
85-

scripts/test-docker.sh

100755100644
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,35 @@ else
280280
fail "dotnet sdk present" "$OUT"
281281
fi
282282

283+
# dotnet aborts at startup without libicu, so this catches a missing ICU package
284+
# even though `dotnet --version` above may have succeeded from a cached response.
285+
if OUT=$(docker run --rm --entrypoint sh "$IMAGE" -c \
286+
'cd /tmp && dotnet --list-sdks' 2>&1 | tr -d '\r'); then
287+
case "$OUT" in
288+
*ICU*|*icu*) fail "dotnet globalization works (libicu present)" "$OUT" ;;
289+
*) pass "dotnet globalization works (libicu present)" ;;
290+
esac
291+
else
292+
fail "dotnet globalization works (libicu present)" "$(printf '%s' "$OUT" | tail -2 | tr '\n' ' ')"
293+
fi
294+
295+
if OUT=$(run_in typst --version 2>&1 | tr -d '\r'); then
296+
pass "typst present ($OUT)"
297+
else
298+
fail "typst present" "$OUT — the PDF Studio extension disables itself without it"
299+
fi
300+
301+
# Compile a real document: proves typst runs and has usable fonts.
302+
if OUT=$(docker run --rm --entrypoint sh "$IMAGE" -c \
303+
'cd /tmp && printf "= Hi\n" > t.typ && typst compile t.typ t.pdf && head -c4 t.pdf' 2>&1 | tr -d '\r'); then
304+
case "$OUT" in
305+
*%PDF*) pass "typst compiles a document to PDF" ;;
306+
*) fail "typst compiles a document to PDF" "unexpected output: $OUT" ;;
307+
esac
308+
else
309+
fail "typst compiles a document to PDF" "$(printf '%s' "$OUT" | tail -2 | tr '\n' ' ')"
310+
fi
311+
283312
if run_in sh -c 'command -v llms' >/dev/null 2>&1; then
284313
pass "llms is on PATH"
285314
else
@@ -292,6 +321,13 @@ else
292321
fail "git present"
293322
fi
294323

324+
# ffmpeg is what the voice extension uses to convert the browser's recording.
325+
if OUT=$(docker run --rm --entrypoint sh "$IMAGE" -c 'ffmpeg -version 2>&1 | head -1' 2>&1 | tr -d '\r'); then
326+
pass "ffmpeg present (${OUT%% https*})"
327+
else
328+
fail "ffmpeg present" "voice input needs it for the voxtype/transcribe modes"
329+
fi
330+
295331
# -------------------------------------------------------------------- CLI ---
296332

297333
section "CLI"
@@ -348,12 +384,44 @@ fi
348384

349385
if OUT=$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" --entrypoint llms "$IMAGE" ls 2>&1); then
350386
pass "llms ls"
387+
case "$OUT" in
388+
*"PDF Studio disabled"*|*"typst not found"*)
389+
fail "PDF Studio extension is enabled" "typst missing from the image" ;;
390+
esac
351391
ENABLED=$(printf '%s' "$OUT" | grep -o 'enabled providers:.*' | head -1)
352392
[ -n "$ENABLED" ] && info "$ENABLED"
353393
else
354394
fail "llms ls" "$(printf '%s' "$OUT" | tail -3 | tr '\n' ' ')"
355395
fi
356396

397+
# ---- voice extension -------------------------------------------------------
398+
# `api` mode should come up from a provider key alone...
399+
OUT=$(docker run --rm -v "$WORKDIR/config:/home/llms/.llms" -e GROQ_API_KEY=test-key-not-real \
400+
-e VERBOSE=1 --entrypoint llms "$IMAGE" ls 2>&1 | tr -d '\r')
401+
case "$OUT" in
402+
*"Using api for voice"*) pass "voice api mode activates from a provider key" ;;
403+
*) fail "voice api mode activates from a provider key" \
404+
"$(printf '%s' "$OUT" | grep -i voice | head -2 | tr '\n' ' ')" ;;
405+
esac
406+
407+
# ...and a voice section in llms.json should choose the endpoint and model.
408+
mkdir -p "$WORKDIR/voice"
409+
cp "$WORKDIR/config/llms.json" "$WORKDIR/config/providers.json" "$WORKDIR/voice/" 2>/dev/null
410+
python3 - "$WORKDIR/voice/llms.json" <<'PYEOF' 2>/dev/null
411+
import json, sys
412+
cfg = json.load(open(sys.argv[1]))
413+
cfg["voice"] = {"url": "http://127.0.0.1:9/v1/audio/transcriptions", "model": "test-model"}
414+
json.dump(cfg, open(sys.argv[1], "w"), indent=4)
415+
PYEOF
416+
chmod -R 777 "$WORKDIR/voice" 2>/dev/null
417+
OUT=$(docker run --rm -v "$WORKDIR/voice:/home/llms/.llms" -e VERBOSE=1 \
418+
--entrypoint llms "$IMAGE" ls 2>&1 | tr -d '\r')
419+
case "$OUT" in
420+
*"model=test-model"*) pass "voice config is read from llms.json" ;;
421+
*) fail "voice config is read from llms.json" \
422+
"$(printf '%s' "$OUT" | grep -i voice | head -2 | tr '\n' ' ')" ;;
423+
esac
424+
357425
# Optional live provider check when a key happens to be in the environment.
358426
LIVE_PROVIDER=""; LIVE_KEY=""
359427
for pair in "groq:GROQ_API_KEY" "openrouter:OPENROUTER_API_KEY" "google:GOOGLE_API_KEY" \

0 commit comments

Comments
 (0)