← Kevin Yoder
Digital Dentistry

Bitewing / Periapical View Classifier

A small EfficientNet-B0 that labels a dental radiograph bitewing vs periapical β€” 99.6% on 550 fully unseen external images β€” packaged as one torch-free ONNX file that three separate systems consume.

Live service Β· :8237 Built Apr 2026 EfficientNet-B0

βš™ How it works πŸ“Š Results

01 Overview

A dental sensor saves a single-channel JPEG with no view metadata β€” nothing on the image says whether it is a bitewing or a periapical. But every downstream pipeline needs to know: tooth-numbering, landmark placement, and view-specific models all branch on it. This is the small model that answers that one question. Give it a radiograph; it returns {label, confidence, processing_ms}.

Its engineering value is not the accuracy β€” the task is visually near-trivial for a CNN β€” but that it ships as a clean, dependency-light, reusable inference artifact. The same exported ONNX is consumed three ways, from one file:

Live Β· HTTP

home-server microservice

A containerized FastAPI service on :8237, up and healthy today β€” the production surface, called over the LAN and Tailscale.

In-process Β· library

Pathology stack

The byte-identical ONNX is copied into the shipped roboflow pathology pipeline and driven in-process to route bitewing vs periapical models β€” no HTTP hop.

Cascade Β· HTTP

Annotation pipeline

The DenPAR view-detector POSTs to the service and accepts the answer above a confidence gate, deferring borderline cases to an older heuristic.

02 Why I built it

It started as a subcomponent of an earlier segmentation research effort (codename DenPAR). That pipeline read the view from a channel of its training PNGs, but real office sensor images are single-channel β€” so every one of them defaulted to "periapical," giving wrong tooth-numbering and wrong dual-arch logic for the bitewings.

There was already a hand-built heuristic view classifier β€” dark-midband detection, content-aware aspect ratio, orientation correction on top of landmark counts. It looked fine internally (~98%) but scored only 94.2% on 550 external images, a 6.7% false-positive rate on periapicals. So I trained a learned model to replace it as primary, on ground-truth-only labels (folder membership is the label; the source datasets know the view by construction β€” no heuristic labels in the loop). I deliberately made it a self-contained service / CLI / library rather than a function buried inside DenPAR, so other projects could adopt it wholesale β€” which is exactly what later happened.

03 What I built & how it works

One trained model, exported once, consumed three ways.

radiograph β€” JPEG / PNG, any view classifier.onnx FP32 Β· 16 MB Β· EfficientNet-B0 β†’ 2 logits home server Β· FastAPI POST /classify Β· :8237 live Β· containerized roboflow in-process ModalityClassifier byte-identical ONNX routes BW vs PA / OPG DenPAR Β· cascade _detect_view() accept if conf β‰₯ 0.55 else heuristic fallback

Fig. 1 β€” the “reusable artifact” thesis in one figure: a single exported ONNX feeds a live service, an in-process library, and a confidence-gated cascade.

  1. Train β€” EfficientNet-B0 pretrained on ImageNet, head replaced with a Linear(1280, 2), all layers fine-tuned; class imbalance handled twice over (inverse-frequency weighted loss and a weighted sampler) on a stratified, fixed-seed split.
  2. Export β€” best checkpoint to ONNX (opset 17, dynamic batch) on a rented GPU, then downloaded; the container ships the ONNX, never PyTorch.
  3. Preprocess β€” decode any PIL-readable image to RGB (the single grayscale channel repeated to three), resize to 224Β², scale, ImageNet-normalize β€” in pure PIL + numpy, so the serving image needs no torch.
  4. Infer β€” a single pre-warmed ONNX Runtime session (CPU) returns two logits; softmax β†’ argmax β†’ {label, confidence, processing_ms}.

04 πŸ›  Skills & tech used

Languages
Python 3.11Bash / SSHDockerfileCompose YAML
ML / AI
EfficientNet-B0 transfer learningfull fine-tuneclass-weighted CE + weighted samplerstratified fixed-seed splitONNX export (opset 17)INT8 static quantizationcosine LR Β· AdamW Β· early stopping
Infra / MLOps
RunPod pod-lifecycle automationONNX Runtime servingFastAPI microserviceDocker (slim, no-torch)UFW Β· Tailscalemulti-system ONNX distribution
Data
multi-source dataset assemblycollision-safe renamingpublic-benchmark acquisition (Zenodo Β· Mendeley)
Techniques
1-channel β†’ 3-channel for ImageNet weightstorch-free numpy preprocessconfidence-gated two-model cascadeexternal-generalization discipline

05 Notable challenges & decisions

A visually easy task, but a few honest engineering lessons around it.

Quantization

INT8 collapsed to identical logits β€” so it ships FP32

Static INT8 quantization produced the same output (~52/48) for every image, regardless of input. Rather than hide it, the service ships the full-precision 16 MB FP32 ONNX and the failure is documented. The root cause was never fully diagnosed β€” a likely-bad calibration step is the leading suspect β€” and the broken 4.3 MB artifact is kept for traceability. (The parent project's own quantization worked; this collapse was specific to this model.)

Dependencies

Keeping PyTorch out of the serving image

Preprocessing originally pulled in a torchvision transform, which dragged torch into a container that had none. The fix was to inline the whole preprocess in pure PIL + numpy with hardcoded ImageNet constants, so the serve image is just ONNX Runtime, FastAPI, and Pillow β€” a lean, single-stage python:3.11-slim build with no PyTorch at all.

Cascade

Replace the heuristic β€” but keep it as a fallback

On the identical 550-image external set the learned model scored 99.6% against the heuristic's 94.2%, so it became primary. But instead of deleting the old classifier, downstream callers accept the CNN above a 0.55 confidence gate and defer to the heuristic below it β€” a two-model cascade rather than trusting either alone.

Evaluation

In-distribution accuracy isn’t accuracy

Validation hit 100% at the first epoch β€” the task is visually clear-cut, and dual class-balancing held up against a real ~5.7:1 periapical majority. But the honest headline is the 99.6% on 550 fully unseen images from independent public datasets, including smartphone photos of X-rays. Confidence dropped on that domain shift (to ~0.67–0.79); accuracy held.

The standalone decision paid off. Building it as a self-contained service / CLI / library β€” rather than a function inside the pipeline that spawned it β€” is why a second project could adopt the exact same ONNX byte-for-byte, and why this one component outlived the research effort it came from.

06 Results

99.6%
accuracy on 550 fully unseen external images
100%
periapical (450 images)
98%
bitewing (100 images)
94.2%
the prior heuristic on the same set
~12–15ms
per image, CPU, warm
1
16 MB ONNX file Β· three consumers

The two bitewing misses (of 100) were near coin-flip β€” predicted periapical at confidence 0.505 and 0.547 β€” i.e. the model correctly flagged its own uncertainty rather than being confidently wrong. Source: task-summary and an in-code record of the external eval; see the honest note below.

07 Companion artifact β€” verified tooth masks

The same abandoned effort yielded a second reusable artifact, credited alongside the classifier. From the public DenPAR dataset's 864 auto-matched per-tooth periapical masks, I built a small Gradio tooth-picker to human-verify the FDI tooth identity behind each one: 686 accepted (707 reviewed, 21 rejected). That verified set became the ground truth the shipped SAM tooth-segmentation fine-tunes were both trained and measured against β€” the benchmark behind a val Dice improvement of 0.9505 β†’ 0.9657 (a decoder-only fine-tune reached 0.9569; a LoRA variant, 0.9657). It is a supporting artifact; the classifier is the primary subject here.

[ Gradio review UI ]
per-tooth mask Β· FDI tooth-picker grid Β· accept / correct
The verification UI: one mask at a time, confirm or correct its FDI number, save to ground truth. (The underlying radiographs are patient imagery and are not shown.)

08 Screenshots

No production screenshots are published β€” the inputs are patient radiographs. Placeholders below stand in for the figures that matter.

[ external-generalization bars ]
Overall 99.6% Β· PA 100% (450) Β· BW 98% (100)
two BW misses annotated at conf 0.505 / 0.547
The one chart that carries the project: near-perfect on 550 unseen images, with the borderline misses marked as “correctly uncertain.”
The BW/PA Classifier's Swagger UI: title with OpenAPI 3.1 badge, a GET /health endpoint and a POST /classify endpoint, and the request/response schemas below.
Proof it is a running service, not a notebook: the API's own docs. A real call β€” POST /classify with a bitewing β€” returns {"label":"BW","confidence":0.73,...} in ~40 ms.
[ CNN vs heuristic ]
99.6% vs 94.2% on the identical 550-image set
Why the learned model replaced the hand-built one β€” measured on the same images, not a friendlier set.

09 Honest status

The classifier is live today as a small microservice on :8237 and is shipped inside the pathology stack; it is the one component of that abandoned research arc still running in production. It is an internal tool β€” reachable over the LAN and Tailscale, with no authentication and no public URL by design, so there is no live demo link here. A three-tier runnable clone exists via the project's reproduce recipe.

Two honest caveats. First, the 99.6% figure is reproducible from the 550 images on disk but not yet serialized to a metrics file β€” today it survives in a task-summary and an in-code record, not a saved JSON; treat it as reproducible, not freshly re-run. Second, the FP32 pivot ships from the working tree rather than a committed build, so a naΓ―ve rebuild from history would regress to the broken INT8 image β€” noted, not hidden. (One naming caution for the record: the credited artifact is the learned EfficientNet at 99.6%, not the older 94.2% heuristic that confusingly shares the same name.)

BW/PA Radiograph View Classifier β€” Build Recipe

Take a bare PC to a running copy of the classifier that labels a dental radiograph Bitewing (BW) vs Periapical (PA). Three ways to run it, easiest first. Pick one.

Status: βœ… Fully verified 2026-07-31 β€” built from a clean checkout on a fresh machine (a laptop with no prior Docker and none of the author's home-server config) by following this recipe. All tiers pass: Tier 1 (docker save β†’ 334 MB loadable image), Tier 2 (docker compose build β†’ container healthy β†’ correct PA/BW), Tier 3a (bare-metal venv + shipped ONNX β†’ correct PA/BW), and Tier 3b (full retrain on a rented RunPod RTX 3090, runpod/pytorch:2.2.1-py3.10-cuda12.1.1, numpy<2 β€” reproduced val-acc 1.0000 / early-stop epoch 6, exported a working ONNX that classified PAβ†’PA & BWβ†’BW, pod self-terminated). Container, bare-metal, and freshly-retrained models all agree. The clean clone lives in clone/ next to this file.

Sensitive data: none. This app has no API keys, no secrets, no LLM, no .env. The shipped model was trained only on public dental-radiograph datasets, so distributing the model file is fine. Do not include any real office radiographs β€” the sample images below are public.


What it is

An EfficientNet-B0 image classifier, exported to ONNX and served as a small HTTP microservice (FastAPI) plus a CLI. Give it one radiograph; it returns {label, confidence, processing_ms}. The serving path is torch-free (pure onnxruntime + Pillow + NumPy), so the runtime image is small.

Reference performance (from the original build): 99.6% on 550 unseen external images (450 PA β†’ 100%, 100 BW β†’ 98%). Warm latency β‰ˆ 12–15 ms/image on CPU.

Repo layout (the clone you receive)

bw-pa-classifier/
β”œβ”€β”€ src/{__init__,dataset,train,export,infer,app}.py   # dataset, training, ONNX export, inference, API
β”œβ”€β”€ scripts/{copy_data,classify,eval_external}.py       # data prep Β· CLI Β· external benchmark
β”œβ”€β”€ tests/{test_dataset,test_infer,test_api}.py
β”œβ”€β”€ models/classifier.onnx                              # ← the trained model (16 MB, ships with the bundle)
β”œβ”€β”€ Dockerfile Β· docker-compose.yml
β”œβ”€β”€ requirements-serve.txt   # runtime deps (no torch)
└── requirements-train.txt   # training deps (torch/torchvision) β€” only needed for Tier 3b

The model file models/classifier.onnx is git-ignored in the original repo, so the bundle ships it alongside the source (or, if published, it is fetched from a release asset on first build).


Prerequisites

Starting from a bare machine? Install the base tools first β€” see the shared ../SETUP.md (Docker Desktop + WSL2 on Windows/macOS, or Docker Engine on Linux; Python 3.11+; git), including the real install gotchas. Then, per tier:

Tier You need
1 β€” prebuilt container Docker (see ../SETUP.md). ~1 GB free. Nothing else.
2 β€” build from source Same as Tier 1, plus the source bundle.
3a β€” bare-metal inference Python 3.11+ and pip. CPU only β€” no GPU required.
3b β€” retrain from scratch Python 3.11+, ideally an NVIDIA GPU + recent CUDA (works on CPU, just slower), and the public datasets (Β§3b). ~0.5 GB of images.

Configuration

None. This is a standalone classifier β€” no keys, no database, no external services, and no .env at all. (This is the recipe principle in action: if it isn't needed, it isn't here.)


Tier 1 β€” Run the prebuilt container (recommended)

Availability: the prebuilt image bundle is available on request β€” it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.

# 1. Load the image (offline tarball)…
docker load -i bw-pa-classifier-image.tar

# 2. Start it
docker compose up -d

# 3. Confirm it's healthy
curl http://localhost:8237/health
# β†’ {"status":"ok","model":"efficientnet_b0_int8"}

That's the whole install. (The efficientnet_b0_int8 string in /health is a cosmetic leftover β€” the service actually serves the full-precision FP32 model; see Notes.) Jump to Verify.

Tier 2 β€” Build the image from source

The Dockerfile and docker-compose.yml are the "how the container is built" documentation.

docker compose up -d --build      # builds the image, then runs it
curl http://localhost:8237/health

Dockerfile (unchanged from source β€” it is already clean and self-contained):

FROM python:3.11-slim
WORKDIR /app
COPY requirements-serve.txt .
RUN pip install --no-cache-dir -r requirements-serve.txt
COPY src/ ./src/
COPY models/classifier.onnx ./models/classifier.onnx
EXPOSE 8237
CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8237", "--workers", "1"]

docker-compose.yml for the standalone clone β€” note what was removed vs. the original on the author's home server (see What was stripped, below):

services:
  bw-pa-classifier:
    build: .
    container_name: bw-pa-classifier
    ports:
      - "8237:8237"          # change the left number to remap the host port
    restart: unless-stopped
    mem_limit: 512m
    healthcheck:
      test: ["CMD", "python3", "-c",
             "import urllib.request; urllib.request.urlopen('http://localhost:8237/health', timeout=4)"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

Tier 3 β€” Bare metal (no Docker)

3a β€” Run inference with the shipped model

python -m venv .venv
# Windows:  .venv\Scripts\activate      Linux/macOS:  source .venv/bin/activate
pip install -r requirements-serve.txt

# Option A β€” the HTTP service (same as the container):
uvicorn src.app:app --host 0.0.0.0 --port 8237 --workers 1

# Option B β€” the CLI (one image or a glob):
python scripts/classify.py samples/pa_example.jpg
# β†’ PA (confidence: 0.84)

--workers 1 is deliberate: the ONNX InferenceSession is not thread-safe, so a single worker serializes requests. models/classifier.onnx must be present (it ships with the bundle).

3b β€” Reproduce the weights (retrain from public data)

Only needed if you want to reproduce the weights rather than use the shipped ONNX. (Verified 2026-07-31: run end-to-end on a rented RunPod RTX 3090 β€” reproduced val-acc 1.0000 in 6 epochs, exported a working classifier; ~10–15 min, β‰ˆ \$0.10; see Status and Provenance β†’ Training hardware.)

First, assemble the dataset (both options need it). Sort grayscale dental radiographs into two label folders β€” see Provenance β†’ Training data for the exact public datasets, counts, and licenses (all public; ~1,534 PA + ~271 BW):

data/
β”œβ”€β”€ pa/   ← periapical X-rays  (.jpg/.jpeg/.png)
└── bw/   ← bitewing X-rays

The train/val/test split is computed automatically (stratified 70/15/15, fixed seed).

Option A β€” rent a cloud GPU, fully automated (exactly how the shipped weights were made)

One script, train/runpod-train-bw-pa.py, does the whole thing and cleans up after itself. It creates a SECURE RTX 3090 pod β†’ polls for SSH β†’ verifies CUDA (aborts + terminates if it fails) β†’ tars & uploads src/ + data/ β†’ installs deps (numpy<2 + torchvision/onnx/onnxruntime) β†’ trains (PYTHONPATH=. python src/train.py) β†’ downloads best.pth immediately β†’ exports ONNX (python src/export.py) β†’ downloads all artifacts into models/ β†’ terminates the pod in a finally so it can't leak a running GPU even on error.

Prereqs on your machine: Python 3 with requests, the ssh / scp / tar CLIs, and an SSH keypair.

Edit these for your account β€” the USER CONFIG block at the top of train/runpod-train-bw-pa.py: 1. RunPod API key β€” create one at runpod.io β†’ Settings β†’ API Keys, then export it (below). 2. SSH keypair β€” ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 if you don't already have one. 3. GPU β€” GPU_TYPE_ID (any RTX 3090/4090). Keep the CUDA-12.1 image + numpy<2 pin to match the shipped weights. (PROJECT_DIR auto-detects this clone, so src/ + data/ are found for you.)

Then, from the clone root:

pip install requests
export RUNPOD_API_KEY="rpa_XXXXXXXX"      # ← your key
python train/runpod-train-bw-pa.py        # rents β†’ trains β†’ exports β†’ downloads β†’ terminates

Measured cost/time: ~10–15 min end-to-end (training itself ~2 min), RunPod SECURE RTX 3090 @ \$0.50/hr β‰ˆ \$0.10. The pod self-terminates β€” confirm none linger on the RunPod dashboard. (If the run's SSH drops mid-training, train/runpod-resume-bw-pa.py resumes on the same still-running pod β€” paste its id/host/port into that file's EDIT block.)

Option B β€” train on your own GPU (or CPU), manual

pip install -r requirements-train.txt
PYTHONPATH=. python src/train.py      # β†’ models/best.pth  (GPU if available, else CPU β€” just slower)
PYTHONPATH=. python src/export.py     # β†’ models/classifier.onnx

Expect ~100% validation accuracy within a few epochs, early-stopped (visually clear-cut task); the honest headline is the 99.6% on external data (scripts/eval_external.py). export also writes an INT8 variant β€” known-broken on this model (identical logits); serve only classifier.onnx.


Provenance β€” data, model & training hardware

Everything a builder needs to reproduce the weights, not just run them. (Tiers 1–2 and 3a use the shipped model and need none of this; it matters for Tier 3b and for honest attribution.)

Training data

All public β€” no clinic images. (Dr. Yoder's office radiographs were used only for testing this classifier, not training it.) data/pa/ and data/bw/, ~1,805 images total:

View ~N Source License
PA 534 Kaggle β€” muhammadsajad/periapical-xrays (via kagglehub) see Kaggle page
PA 1,000 DenPAR β€” Rasnayaka S., Leuke Bandara D., Jayasundara A., et al. "DenPAR: Annotated Intra-Oral Periapical Radiographs Dataset for Machine Learning." Sci Data 12, 1615 (2025). Zenodo DOI 10.5281/zenodo.16645076 Β· paper https://doi.org/10.1038/s41597-025-05906-9 CC-BY 4.0
BW 271 Kaggle β€” shubhamskg/bitewing-datasets see Kaggle page

DenPAR (public on Zenodo β€” no credentials) is the source of this classifier's PA training images and of the verified tooth masks credited in the rΓ©sumΓ©; its source population is Sri Lankan. The two Kaggle sets need a free Kaggle API token (~/.kaggle/kaggle.json) to download; DenPAR does not.

External evaluation set (550 fully-unseen images β€” the source of the 99.6% headline): - PA, 450 β€” Fretes LΓ³pez, V., Adorno, C. G., Mello-RomΓ‘n, J. C., Alderete, D., DΓ­az, F., MΓΌller, M., & Escobar-Torres, R. (2024). Dataset of dental radiographs for the study of periapical lesions (v1) [Data set]. Zenodo. https://doi.org/10.5281/zenodo.13772918 β€” CC BY 4.0. - BW, 100 β€” Kybic, J., TichΓ½, A., & Kunt, L. (2023). Dental caries in bitewing radiographs. Mendeley Data, v1. https://doi.org/10.17632/4fbdxs7s7w.1 β€” CC BY-NC 3.0 (non-commercial β€” fine to reproduce/evaluate; not for commercial redistribution).

Clinic-data convention (not triggered here, shown for the standard): where a project trains on Dr. Yoder's own radiographs, they're listed as "de-identified [PA/BW] radiographs from Dr. Yoder's practice β€” image files carry no embedded patient identifiers (verified: no DICOM/EXIF PHI tags); distributed with the practice owner's authorization." The no-embedded-PHI claim is verified by inspecting the files' metadata when the clone is built, not merely asserted.

Pretrained model / weights

  • EfficientNet-B0, ImageNet-pretrained (torchvision.models.EfficientNet_B0_Weights.IMAGENET1K_V1) β€” downloaded automatically by torchvision on first training run. The classifier head is replaced with Linear(1280, 2) and the whole network is fine-tuned.
  • No other external checkpoints. The shipped models/classifier.onnx is this fine-tuned model, ONNX-exported (opset 17).

Training hardware (so a RunPod/Vast.ai session is trivial to match)

The reference weights were trained on a rented cloud GPU β€” you do not need to own one:

Provider RunPod (SECURE cloud) β€” a Vast.ai instance with the same GPU works identically
GPU 1Γ— NVIDIA RTX 3090 (24 GB) β€” a single mid-range GPU is ample
Container image runpod/pytorch:2.2.1-py3.10-cuda12.1.1-devel-ubuntu22.04
Critical pin numpy<2 (avoids a torch/onnxruntime ABI clash)
Run 6 epochs, early-stopped (patience 5) β†’ val-acc 1.0000. Terminate the pod when done.
Measured (2026-07-31 re-run) Training itself ~2 min (6 epochs); full rented session ~10–15 min (dominated by the 432 MB data upload + pod provisioning). RunPod SECURE RTX 3090 @ \$0.50/hr β‰ˆ \$0.10 for the session.

CPU-only training also works (just slower). On Vast.ai, pick any RTX 3090/4090, match the CUDA-12.1 PyTorch image and the numpy<2 pin, and you'll reproduce the same result.


Verify

The clone ships two public sample radiographs in samples/ (a periapical from the Zenodo set, a bitewing from the Mendeley set β€” see Provenance). Actual outputs from the verify build:

# health
curl http://localhost:8237/health
# β†’ {"status":"ok","model":"efficientnet_b0_int8"}

# classify the periapical sample
curl -s http://localhost:8237/classify -F "file=@samples/pa_example.jpg"
# β†’ {"label":"PA","confidence":0.84,"processing_ms":115}

# classify the bitewing sample
curl -s http://localhost:8237/classify -F "file=@samples/bw_example.png"
# β†’ {"label":"BW","confidence":0.73,"processing_ms":30}

Success = /health returns 200 with "status":"ok", and /classify returns the correct label. Confidence on these domain-shifted public images is moderate (~0.7–0.85) β€” the model signalling honest uncertainty on out-of-distribution inputs, not a failure; accuracy holds even as confidence drops. (The first /classify after startup is slower β€” ~100 ms cold β€” then ~12–30 ms.)

Notes & gotchas

  • /health says efficientnet_b0_int8, but the service is FP32. Cosmetic stale label from the original build; behavior is the full-precision model. (The clone commits the FP32 state as canonical so a clean checkout builds the working model β€” the original repo left that pivot uncommitted.)
  • INT8 quantization is broken here β€” shipped FP32 (16 MB) instead of INT8 (4 MB). Documented, not hidden. Only classifier.onnx is ever served.
  • Single worker β€” ONNX InferenceSession isn't thread-safe; keep --workers 1 and serialize concurrent demo requests.
  • Grayscale in, RGB model β€” single-channel radiographs are repeated to 3 channels and ImageNet-normalized; handled internally, no action needed.
  • Training only (3b): if you hit a NumPy/torch/ONNX ABI clash, pin numpy<2 in the training environment (the original cloud build used that pin). The serving path is unaffected.
  • First request after startup is slower (~100 ms cold) while the ONNX session warms; subsequent calls are ~12–15 ms.

What was stripped from the home-server version (recipe minimalism in action)

The clone is deliberately simpler than the app as it runs on the author's server. Removed because a fresh user doesn't need them:

  • External homeserver Docker network. The original compose joined a shared bridge network on the author's home server (networks: homeserver: external: true) so sibling containers could reach it by name. A standalone clone doesn't have that network β€” dropped; the container just publishes port 8237.
  • Nothing else to strip here β€” this project never used the author's LiteLLM gateway or any multi-machine setup, or any credential. (Other projects' recipes will strip more: the LiteLLM paid/free/backup setup, Juice GPU-over-LAN, etc.)