-
Notifications
You must be signed in to change notification settings - Fork 14
Dev Server README
Auto-generated from
server-rs/README.md. Do not edit this page manually.
This is the LrGeniusAI backend. It's a single binary (geniusai-server)
that speaks the HTTP API the Lightroom plugin expects — see
../CLAUDE.md for the architecture overview and
docs/wiki/Dev-Backend-API.md for the
endpoint reference.
Workspace layout: crates/lrg-common, lrg-store (LanceDB), lrg-imaging,
lrg-ml (ONNX Runtime via ort), lrg-analysis, lrg-providers (LLM
clients), lrg-llama (in-process llama.cpp, behind the llamacpp feature),
lrg-mlx (supervises the Apple silicon MLX sidecar), lrg-api (axum
routers), lrg-server (the binary).
There are two local LLM backends, selected as the llamacpp and mlx
providers. They are independent — a build can have either, both, or neither —
and both are served by the same LocalEngine trait in
lrg-providers/src/local_provider.rs. Their build requirements differ, so they
are covered separately below.
Which one ships is a per-platform decision. Release builds enable
llamacpp on Windows only; the macOS build is MLX-only and does not
compile the feature at all (see cargo_features in the release matrix,
.github/workflows/release.yml). Enabling llamacpp locally on macOS to
compare the two engines still works — just don't re-add it to the macOS
release job.
cd server-rs
cargo build --release -p lrg-server
./target/release/geniusai-server --db-path /path/to/lrgenius.dbcargo test --workspace and cargo clippy --workspace --all-targets should
both be clean before sending changes.
The server listens on 127.0.0.1:19819. Both halves are environment
variables, which is how the Docker image binds to all interfaces:
| Env var | Default |
|---|---|
GENIUSAI_HOST |
127.0.0.1 |
GENIUSAI_PORT |
19819 |
GENIUSAI_PORT=19833 ./target/release/geniusai-server --db-path /path/to/lrgenius.dbAn unparseable GENIUSAI_PORT falls back to the default rather than refusing
to start, so a typo costs you the port you asked for, not the server.
Moving the port is mainly useful for running a second instance beside an
installed one — a dev build cannot share 19819 with the copy the plug-in
auto-launches. Point the plug-in at it with Plug-in Manager → Backend server
URL (http://127.0.0.1:19833); that setting is the only place the plug-in
learns the address, so nothing else needs changing. Note that
SearchIndexAPI.startServer still launches the installed binary on the
default port, so start a custom-port build yourself.
The ML golden tests — SigLIP2, BioCLIP 2 and the face pipeline — compare real inference against torch-computed reference values. They need their model files on disk and skip when those are absent, which the Rust test harness reports as a pass. That is fine on a working copy, and dangerous in CI: a green run is otherwise indistinguishable from one where the numerical checks never executed.
LRG_REQUIRE_GOLDENS turns the skip into a failure for the families you name,
so a job that provisions assets can prove it used them:
LRG_REQUIRE_GOLDENS=face cargo test --workspace # one family
LRG_REQUIRE_GOLDENS=face,siglip cargo test --workspace # a subset
LRG_REQUIRE_GOLDENS=all cargo test --workspace # every familyFamily names are face, siglip and bioclip. Per-family rather than a
single switch because they differ in cost by more than an order of magnitude
(89 MB, 2.2 GB and 834 MB respectively).
CI uses both halves: server-rs-tests.yml downloads the face pair on every PR
and sets LRG_REQUIRE_GOLDENS=face, while the nightly golden-tests-full.yml
fetches all three and runs with all. The models resolve through the same
LRG_*_ONNX env vars and ~/.cache/lrgenius/models/ fallback the server uses,
so a machine that has run the plugin's model download needs no extra setup.
The in-process local LLM is behind a cargo feature that is off by default.
Without it the llamacpp provider reports that the build has no local-model
support and /v1/llm/catalog returns supported: false, so the plugin's "Local AI
Model" settings have nothing to work with. It is opt-in because it compiles
llama.cpp from source: that needs cmake and libclang (for bindgen) and adds
minutes to a cold build, which nobody working on an unrelated crate should pay.
# macOS: libclang ships with the Xcode command line tools; brew install cmake
# Linux: apt install cmake libclang-dev
# Windows: cmake + LLVM, plus the Vulkan SDK (VULKAN_SDK must be set — the build
# panics without it). Vulkan, deliberately not CUDA: see crates/lrg-llama/Cargo.toml
cargo build --release -p lrg-server --features llamacpp
cargo clippy --workspace --all-targets --features llamacppThen point it at a model — see Local LLM below for the env vars — or download one from the plugin's settings. The release workflow builds with this feature on Windows only, so the shipped macOS binary does not have it.
Tests that exercise a real model are #[ignore]d, since they need a multi-GB
GGUF on disk:
export LRG_TEST_MODEL_GGUF=/path/to/model-Q4_K_M.gguf
export LRG_TEST_MMPROJ_GGUF=/path/to/mmproj-BF16.gguf
cargo test -p lrg-llama --test engine_smoke -- --ignoredOne gotcha when testing through Lightroom: the plugin auto-launches the
installed binary, not your dev build. startServer pings port 19819 first and
short-circuits when something already answers, so start your feature-enabled
build by hand and the plugin will use that instead.
mlx is the second local backend, and it is not behind a cargo feature:
lrg-mlx only spawns and talks to a helper process, so it costs nothing to
compile and availability is a runtime question (Apple silicon + an installed
helper). What it does need is that helper built, and swift build will not do
— SwiftPM on the command line cannot compile MLX's Metal shaders, and the
binary it produces dies on the first inference with "Failed to load the default
metallib":
xcodebuild -downloadComponent MetalToolchain # ~690 MB, once per machine
cd native/mlx-sidecar
xcodebuild build -scheme lrgenius-mlx -destination 'platform=macOS,arch=arm64' \
-configuration Release -derivedDataPath .build/xcode \
-skipPackagePluginValidation -skipMacroValidation
export LRG_MLX_SIDECAR=$PWD/.build/xcode/Build/Products/Release/lrgenius-mlxWithout it, /v1/llm/catalog reports mlx.supported: false with a reason naming
what is missing (wrong architecture, or no helper found). The sidecar is
resolved from LRG_MLX_SIDECAR, then from next to the running server, which is
how the shipped .pkg finds it. See
native/mlx-sidecar/README.md for the
JSON-lines protocol, why it is a separate process, and the model layout.
export LRG_TEST_MLX_MODEL_DIR=/path/to/mlx-community/gemma-4-e4b-it-4bit
cargo test -p lrg-mlx --test sidecar_smoke -- --ignoredPOST /v1/models/clip/downloads + GET /v1/models/clip/downloads fetch the fp16
ONNX assets (siglip2_image_fp16.onnx, siglip2_text_fp16.onnx,
tokenizer.json) and place them at the LRG_SIGLIP_* paths below,
pulling CI-exported ONNX assets from a release instead of the fp32
checkpoint from Hugging Face.
They come from the fixed model-assets-v1 tag
(MODEL_ASSETS_RELEASE_TAG in routes/clip.rs), not from the binary's
own version tag: the assets change far less often than the app, so
re-uploading them on every release would be wasted. Bumping them means
bumping that constant and the tag together (e.g. model-assets-v2).
That release exists with all three assets attached, so the endpoint works
on any build. To export the models yourself instead — for a local change,
or to avoid the download — run the same script the build-model-assets CI
job runs:
cd server-rs
uv run --project scripts --with onnxscript \
python scripts/export_siglip2_fp16.py --output-dir /path/to/models/siglip2--with onnxscript is required: torch >=2.9's torch.onnx.export imports
it unconditionally even with dynamo=False. This downloads the SigLIP2
checkpoint from Hugging Face, traces it natively in fp16, and writes
siglip2_image_fp16.onnx (~860 MB), siglip2_text_fp16.onnx (~1.4 GB),
and tokenizer.json into the output directory.
Verify the export against the checked-in goldens (no torch/open_clip needed for this half, just onnxruntime/numpy/tokenizers):
uv run --project scripts python scripts/export_siglip2_fp16.py \
--verify-only --output-dir /path/to/models/siglip2Both towers should score cosine similarity >0.99999 against the fp32 goldens. Then point the server at the files:
export LRG_SIGLIP_IMAGE_ONNX=/path/to/models/siglip2/siglip2_image_fp16.onnx
export LRG_SIGLIP_TEXT_ONNX=/path/to/models/siglip2/siglip2_text_fp16.onnx
export LRG_SIGLIP_TOKENIZER=/path/to/models/siglip2/tokenizer.jsonWithout these set, lrg-ml falls back to ~/.cache/lrgenius/models/{siglip2_image,siglip2_text}.onnx
and ~/.cache/lrgenius/models/tokenizer.json.
Two artifacts, because BioCLIP is a CLIP rather than a classifier: the ViT-L/14
image tower, and a precomputed Tree-of-Life zero-shot head that the taxonomy
actually lives in. Upstream's head is 2.66 GB fp32 over 867,455 taxa — larger
than the model — so the export prunes it per
scripts/bioclip_taxa_filter.toml, which documents both the rules and what
pruning costs at the species rank.
uv run --project scripts --with onnxscript \
python scripts/export_bioclip2_fp16.py --output-dir /path/to/models/bioclip2The script prints the kept taxon count and every asset's size; the whole set lands at roughly 880 MB. Verify without re-exporting, and optionally compare the pruned head's top-1 against the full upstream head on real photos — the measurement that justifies the pruning rules:
uv run --project scripts python scripts/export_bioclip2_fp16.py \
--verify-only --output-dir /path/to/models/bioclip2 \
--fixtures /path/to/some/wildlife/photosBoth the export and --verify-only score the goldens through one
bioclip_preprocess(), which is what lrg-ml::bioclip_pre reproduces — the two
sides used to preprocess differently and reported cosines around 0.97 that read
like fp16 damage but were pure measurement error.
Changing the taxa rules does not require re-exporting the tower, which takes minutes and only changes when the checkpoint does:
uv run --project scripts python scripts/export_bioclip2_fp16.py \
--skip-tower --output-dir /path/to/models/bioclip2Then point the server at the files:
export LRG_BIOCLIP_IMAGE_ONNX=/path/to/models/bioclip2/bioclip2_image_fp16.onnx
export LRG_BIOCLIP_TAXA_BIN=/path/to/models/bioclip2/bioclip2_taxa.bin
export LRG_BIOCLIP_TAXA_JSON=/path/to/models/bioclip2/bioclip2_taxa.jsonWithout these, lrg-ml falls back to
~/.cache/lrgenius/models/bioclip2_{image.onnx,taxa.bin,taxa.json}, which is
where POST /v1/models/bioclip/downloads — and POST /v1/models/assets/downloads, which
the plugin actually calls — put them. Note the fallback name for the tower is
bioclip2_image.onnx, without the export script's _fp16 suffix.
Assets are published by .github/workflows/model-assets-bioclip.yml to the
fixed bioclip-assets-v1 tag, independent of SigLIP2's model-assets-v1.
That release does not exist yet, so the download route reports a 404 until
the workflow has run once; an export placed in the cache directory by hand works
in the meantime.
Two files: OpenCV Zoo's YuNet detector (already ONNX, downloaded as-is) and
facenet-pytorch's Inception-ResNet-v1 with the VGGFace2 weights (exported).
scripts/export_face_models.py produces both and verifies them:
uv run --no-project server-rs/scripts/export_face_models.py --output-dir face-assetsIt runs with --no-project on purpose — facenet-pytorch pins torch<2.3
and numpy<2.0, which scripts/pyproject.toml cannot satisfy, so the script
carries its own PEP 723 dependency header.
Then point the server at the files:
export LRG_YUNET_ONNX=/path/to/face-assets/yunet_face_detection.onnx
export LRG_FACENET_ONNX=/path/to/face-assets/facenet_vggface2.onnxWithout these, lrg-ml falls back to
~/.cache/lrgenius/models/{yunet_face_detection.onnx,facenet_vggface2.onnx},
which is where POST /v1/models/assets/downloads puts them. Assets are published
by .github/workflows/model-assets-face.yml to the fixed face-assets-v1
tag, independent of the other two families.
These replaced InsightFace's buffalo_l (SCRFD + ArcFace), which was
resolved from INSIGHTFACE_ROOT and could not be redistributed — face
detection was the one family the plugin could not download for you. Note the
two produce embeddings in different spaces at the same 512 dims, so face
rows carry lrg_ml::faces::MODEL_ID and anything written by the old pair is
ignored by clustering and re-queued for detection.
Only present in builds compiled with the llamacpp cargo feature; without
it the llamacpp provider reports that the build has no local-model
support and /v1/llm/catalog returns supported: false.
Models are GGUF pairs — the weights plus an mmproj vision projector,
which is what lets the model see the photo. GET /v1/llm/catalog lists what
is installed and what can be downloaded; POST /v1/llm/downloads fetches
a catalog entry. Discovery looks at, in order: the explicit env overrides,
LRG_LLAMA_MODEL_DIR (default ~/.cache/lrgenius/models/llm/), and any
GGUFs already under ~/.lmstudio/models, so a model you downloaded in LM
Studio is offered without a second copy.
# Point at specific files (wins over any directory scan)
export LRG_LLAMA_MODEL_GGUF=/path/to/model-Q4_K_M.gguf
export LRG_LLAMA_MMPROJ_GGUF=/path/to/mmproj-BF16.gguf
# Tuning. The plugin's "Local AI Model" settings send these per request and
# take precedence; these are the fallback when it does not.
export LRG_LLAMA_N_CTX=8192 # context window
export LRG_LLAMA_N_PARALLEL=1 # photos decoded concurrently
export LRG_LLAMA_GPU_LAYERS=999 # 0 = CPU only; anything that does not fit stays on the CPUn_ctx and n_parallel trade against each other: the whole group of
photos has to fit the context window alongside the shared prompt prefix, and
the engine reduces n_parallel with a warning rather than overrunning it.
Changing any of these reloads the model on the next request.
A note on chat templates: llama.cpp's C API applies only templates it
recognises, so a model shipping a modern Jinja template (Gemma 4's is 18 KB
of macros) is refused outright. The engine therefore picks a built-in
template from the GGUF's general.architecture and confirms it applies
before use, logging which one it chose. cargo run -p lrg-llama --example probe_template -- model.gguf prints what a GGUF reports and which
templates llama.cpp will accept — start there if a new model misbehaves.
Same role as the section above, different artifacts: an MLX model is a
directory, not a file — a Hugging Face repo snapshot with config.json, one
or more safetensors shards, and the tokenizer files. Discovery therefore looks
for directories that contain a config.json and at least one .safetensors
(the config alone would match a half-finished download).
GET /v1/llm/catalog reports MLX under a nested mlx key alongside the GGUF half,
including supported/reason, and POST /v1/llm/downloads takes an MLX
catalog id on the same route — the id alone picks the backend, so there is one
download queue rather than two competing for the network.
# Use exactly this model (wins over any directory scan)
export LRG_MLX_MODEL_DIR=/path/to/mlx-community/gemma-4-e4b-it-4bit
# Root that downloads land in and discovery scans
export LRG_MLX_MODEL_ROOT=~/.cache/lrgenius/models/mlxDiscovery also picks up ~/.lmstudio/models (LM Studio has shipped an MLX
engine for a long time) and the huggingface-cli cache at
~/.cache/huggingface/hub, so a model pulled by hand needs no second copy.
There are no tuning knobs to match llama.cpp's n_ctx/n_parallel/GPU layers,
and that is not an oversight: GuidedGenerationLoop.run in mlx-swift-lm
allocates a fresh KV cache per call, so there is no pinned prompt prefix to
size a context window around, the preferred batch size is 1, and the grammar is
compiled per request. The prompt is still sent pre-split and stable-first so the
ordering is right if that changes upstream.
Setting LRG_DISABLE_KLEIDIAI=1 turns off ONNX Runtime's arm64 KleidiAI
convolution kernels for every session. It exists only as a field escape
hatch: onnxruntime 1.24 leaked ~25-31 MB per photo in those kernels (a
14k-photo indexing run reached a 45 GB footprint), which is fixed in the
1.28 build we ship. If unbounded memory growth during indexing ever
reappears on an arm64 machine, set this to confirm the cause before
digging further.
It costs roughly 3x indexing throughput on Apple Silicon (measured end to end: SigLIP2 316 ms -> 952 ms per photo, face detection 37 ms -> 60 ms), so leave it unset in normal use. Embeddings are unaffected either way — search rankings and distances are identical.
Dockerfile here builds and ships the compiled binary (multi-stage:
rust:1-bookworm builder, debian:bookworm-slim runtime). Model files
still need to be supplied via a mounted /models volume following the
env vars above (the Dockerfile pre-sets them to /models/siglip2/... and
/models/face/...) — see the Dockerfile's own comments for the
ort/ONNX-Runtime dylib packaging details.
docker build -t geniusai-server -f server-rs/Dockerfile server-rs
docker run -p 19819:19819 -v /path/to/data:/data -v /path/to/models:/models \
-e GENIUSAI_HOST=0.0.0.0 geniusai-serverOr via Compose: docker compose -f ../docker-compose-dev.yml up -d --build.
The image is built without the llamacpp feature and has no MLX sidecar, so
neither local backend is available in a container — point the containerized
server at Ollama or LM Studio, or use a cloud provider. Add
--features llamacpp to the Dockerfile's cargo build (plus cmake and
libclang-dev in the builder stage) if you want in-process inference there.
Not yet implemented: the periodic face-clustering and database-backup
schedulers — the GENIUSAI_FACES_CLUSTER_* / GENIUSAI_BACKUP_* env vars
are currently no-ops here.
LanceDB's defaults are sized for servers: a 6 GiB index cache and a 1 GiB
file-metadata cache per session. Running next to Lightroom on a desktop,
that headroom is indistinguishable from a leak, so lrg-store opens its
connection with an explicit, much smaller session. Both caps are pure
speed/memory tradeoffs (a miss re-reads from disk) and can be overridden,
in MiB:
| Env var | Default |
|---|---|
GENIUSAI_LANCE_INDEX_CACHE_MB |
128 |
GENIUSAI_LANCE_METADATA_CACHE_MB |
128 |
Indexing reads and normalises several photos at once, which is the other place a batch can grow its peak: that many originals — 25-50 MB apiece for raw — are resident before any of them has been shrunk to a normalised JPEG.
| Env var | Default |
|---|---|
GENIUSAI_INDEX_DECODE_CONCURRENCY |
min(3, cores) |
Decode is pure CPU and by far the largest sequential cost of a batch without
an LLM in it, so this trades wall time against peak memory directly. 1
restores the one-at-a-time behaviour.
The other half of memory behaviour during a long indexing run is
compaction — see Store::optimize_all and the constants above it. To
reproduce and measure the write-path memory profile without Lightroom in
the loop:
cargo run --release -p lrg-store --example memgrow -- 3000It replays the exact per-photo write pattern index_one performs and
prints RSS plus LanceDB cache size every 100 photos.
This is the sole backend implementation (the earlier Python/Flask server
has been retired) and it ships on main. Still under active development,
so check git log before assuming a given endpoint or feature behaves the
way an older note describes.