Documentation: https://ash-hun.github.io/LLMClick/ (generated from this README, the channel READMEs and the code; see website/README.md).
Config-driven custom model building. One YAML describes one custom model; pipeline.recipe picks the recipe that
builds it and pipeline.stages picks the steps to run. Every recipe runs on the same pipeline runner, which skips
what is already built, shows progress, and never hands on a trained model that has not passed validation.
| Family | Recipe (pipeline.recipe) |
Trains | Model catalog (model.architecture) |
|---|---|---|---|
| LLM | llm_sft |
conversations, loss on every assistant turn | transformer (Qwen3-0.6B, 1.7B), hybrid (Qwen3.5-0.8B, 2B) |
| LLM | llm_instruction |
instruction/input/output records | same |
| LLM | llm_dpo |
chosen/rejected preference pairs | same |
| LLM | llm_grpo |
prompts scored by a reward function | same |
| LLM | llm_decision_sft |
decision questions: a head scores the options (Jev, jeff, Jeeves stage 1) | same, plus model.head: readout or pointer |
| LLM | llm_decision_cispo |
the same questions with reasoning before the decision, by reinforcement (Jeeves stage 2) | same |
| Embedding | embedding_contrastive |
query/positive/negative triples (InfoNCE) | bi_encoder (Qwen3-Embedding-0.6B) |
| TTS | tts_piper |
recordings and their texts: a Piper (VITS) voice fine-tuned from a Piper checkpoint, exported to ONNX | model.quality: medium, high (Piper's sizes) |
| Wake word | wakeword |
recordings of the agent's name ("Jarvis") and of everything else: an openWakeWord detector, exported to ONNX | openWakeWord's dnn head on its frozen speech features |
| Evaluation | evaluation_custom |
nothing: scores a checkpoint of any experiment above on any rows, with that recipe's metrics | the experiment's |
| Evaluation | evaluation_benchmark |
nothing: scores a checkpoint on public benchmarks (lm-evaluation-harness), one cached stage per benchmark | the experiment's (LLM) |
| Evaluation | evaluation_compare |
nothing: several checkpoints (the base model among them) on the same benchmarks and rows; table, deltas with intervals, polygon chart | the experiments' |
| Evaluation | evaluation_decision |
nothing: a decision checkpoint on Jev rows or the JevBench tiers, without and with reasoning; accuracy, NLL, ECE, per tier, latency | the experiment's (decision) |
| Evaluation | evaluation_embedding |
nothing: an embedding checkpoint on MTEB tasks (mteb), one cached stage per task | the experiment's (embedding) |
| Evaluation | evaluation_wakeword |
nothing: a wake word detector on recordings and hours of audio it never heard; miss rate per noise condition, false accepts per hour, the threshold sweep | the experiment's (wake word) |
| Data | data_synthetic |
nothing: rows for the recipes above from seeds through a teacher model (seeds, prompts, evolve, respond, verify, select) | teachers: anthropic, openai, ollama, local |
| Data | data_speech |
nothing: a Piper training set from seeds: texts (a teacher, or the seeds as they are) read by a Piper voice, checked by Whisper | voice: any Piper .onnx, one speaker or many |
The decision recipes follow the Jev request format. The readout head and one-pass training follow jeff; the pointer head, reasoning chains and CISPO follow Jeeves. Both are reimplemented on this framework's training loop, so their released checkpoints do not load here.
LLMClick/
├── main.py # `uvicorn main:app` from the repository root; the app itself is core/api/app.py
├── configs/ # one YAML = one custom model; a directory per family (llm/, embedding/)
├── samples/ # tiny rows per recipe so the shipped configs run offline
├── core/ # the framework, shared by every channel
│ ├── stage.py # Stage: one fingerprinted, rerunnable unit of work
│ ├── pipeline.py # Pipeline: runs a recipe's stages; build()/load() turn a config into one
│ ├── progress.py # Progress, BarProgress (terminal), StateProgress (API jobs)
│ ├── registry.py # Registry, RECIPES, CHANNELS
│ ├── config/ # schema.py (BaseConfig), experiment.py (directories, manifest)
│ ├── cli.py # llmclick run|validate|recipes|serve
│ ├── settings.py # process settings from the environment and environment/.env (LLMCLICK_ENV)
│ ├── api/ # app.py; routers: config_channel, job_channel, system_channel; store.py, guard.py
│ └── utils/ # files (hash, lock), device
├── modeling/ # Modeling channel: config -> trained, validated model
│ ├── config.py # ModelingConfig: tracker, validation bounds
│ ├── stages.py # TrainStage, ValidateStage (templates every recipe fills in)
│ ├── pipeline.py # ModelingPipeline: training always brings validation
│ ├── tracker.py # wandb replay
│ ├── tuning/ # in-process fine-tuning shared by the families below
│ │ ├── backbone.py # Backbone: base of every model catalog entry
│ │ ├── method.py # TrainingMethod: base of every training method
│ │ ├── loop.py # the one training loop (accumulation, schedule, resume)
│ │ ├── stages.py # data, train, validate
│ │ ├── sources.py # row sources: local_jsonl, huggingface
│ │ └── config.py pipeline.py # TuningConfig, TuningPipeline
│ ├── llm/ # family: generative language models
│ │ ├── models/ # Model Catalog: base.py (LLMBackbone), transformer.py, hybrid.py
│ │ │ └── heads/ # decision heads on top of a backbone: base.py (DecisionHead), readout, pointer
│ │ ├── methods/ # Training Method: base.py (LLMMethod), sft, instruction, dpo, grpo
│ │ │ └── decision/ # base.py (DecisionMethod: rows, metrics, calibration), sft, cispo
│ │ └── config.py pipeline.py # one config class and one registered recipe per method
│ ├── embedding/ # family: text embedding models
│ │ ├── models/ # Model Catalog: base.py (EmbeddingBackbone), bi_encoder.py
│ │ ├── methods/ # Training Method: contrastive.py
│ │ └── config.py pipeline.py
│ ├── tts/ # family: text-to-speech (tts_piper)
│ │ ├── piper.py # the Piper program (GPL) as a separate process: speak, fit, export
│ │ ├── audio.py asr.py # WAV measurements; Whisper transcripts and word error rate
│ │ └── config.py stages.py pipeline.py
│ └── wakeword/ # family: wake word detection (wakeword)
│ ├── features.py # openWakeWord's frozen front end on onnxruntime: audio -> embeddings every 80 ms
│ ├── detector.py # the DNN head: trained with torch, exported and scored in ONNX
│ └── config.py stages.py pipeline.py
├── data/ # Data channel: seeds -> prompts -> evolve -> respond -> verify -> select (data_synthetic)
│ ├── teachers.py # anthropic, openai, ollama, local behind one complete()
│ ├── config.py rows.py ... # one module per stage; rows.py: natural keys, atomic files, per-item caches
│ └── speech/ # data_speech: synthesize, verify, select after the shared seeds and prompts
├── evaluation/ # Evaluation channel: config -> scores of a trained model, report.json
│ ├── source.py # SourceExperiment: an experiment's config read back, its checkpoint located
│ ├── stages.py # rows, score, report (evaluation_custom)
│ ├── benchmark.py # presets, one score stage per benchmark over lm-evaluation-harness (evaluation_benchmark)
│ ├── compare.py # runs, verdicts against the base, polygon SVG, Markdown summary (evaluation_compare)
│ ├── decision.py # jevbench source, decide without and with reasoning, ECE, tiers, latency (evaluation_decision)
│ ├── embedding.py # mteb encoder wrapper, one stage per MTEB task (evaluation_embedding)
│ ├── wakeword.py # clips, score traces, miss rate and false accepts per hour over thresholds (evaluation_wakeword)
│ └── config.py pipeline.py # (README: design and milestones)
├── tests/core/ tests/modeling/
├── docs/ # api-spec.md, screen-spec.md
├── environment/ # Dockerfile, docker-compose.yml, .env.sample
│ └── piper/ # the Piper program's own locked environment (GPL), built by setup.sh
└── output/
├── _stages/<stage>-<fingerprint>/ # what each stage built; shared by every experiment with the same inputs
└── <name>-<hash>/ # one experiment: config.yaml, manifest.json, pipeline.log, links to its stages
evaluation/ scores what modeling/ built: evaluation_custom measures any checkpoint of an experiment (validate,
train, base, or a directory) on any rows with the metrics of the recipe that trained it; evaluation_benchmark
scores it on public benchmarks through lm-evaluation-harness (uv sync --extra eval), one cached stage per
benchmark, with presets for the small-model panel and Korean; evaluation_compare puts several checkpoints, the
base model among them, through the same benchmarks and rows and reports the differences with their intervals, a
polygon chart and a Markdown summary; evaluation_decision measures a decision model on Jev rows or the JevBench
tiers without and with reasoning (accuracy, NLL, ECE, per tier against chance, latency); evaluation_embedding
runs an embedding model on MTEB tasks; evaluation_wakeword measures a wake word detector's miss rate under noise and
false accepts per hour of audio without the name. All write report.json with the overlap between the evaluation items and
the experiment's training rows (exact matches and n-gram near-duplicates) next to the scores, send the report to
wandb with tracker.enabled, and are listed by GET /api/evaluations (evaluation/README.md has the details). data/ produces rows: data_synthetic makes them from seeds through a teacher model (Anthropic, OpenAI-format, Ollama or a local checkpoint), evolves and verifies them, removes duplicates and leaks, and writes a recipe's rows with a manifest; every stage is cached by its inputs and safe to rerun, so a crash or a spent budget resumes where it stopped (data/README.md). Loading open datasets (data_open) is designed there and not built. A channel is a
package that registers recipes; it joins by adding its name to CHANNELS in core/registry.py and reuses Stage,
Pipeline, Progress and Experiment as they are.
pipeline:
recipe: llm_sft # which pipeline builds this model
name: my-model
stages: [data, train] # optional; default all. Stages they read are added, train always brings validate- Fingerprints. A stage's fingerprint is its own config inputs (plus the content of local files it reads) and
the fingerprints of the stages it reads. Its directory is
output/_stages/<stage>-<fingerprint>/, so two configs that differ only in training settings share the data stages, and a rerun of the same config builds nothing. Each training method, backbone and head class carries aversion; bumping one after changing its code rebuilds the experiments that use that part and leaves every other recipe's cache alone. - Reruns. A stage is recorded only when it finishes. A crashed run is resumed by running the same command
again; training continues from
resume.ptin the train stage directory and ends with the same weights as an uninterrupted run: batches, learning rates and the samples of a sampling method (llm_grpo,llm_decision_cispo) are all derived from the seed and the step. Withmethod.iterationsabove 1 a snapshot is written only between groups of steps that share samples, so a resume never lands inside such a group. - Locks. A stage directory is locked while it is built, so a CLI run and an API job asking for the same stage never build it twice; the second one says that it is waiting (progress note, log) and then reuses the result.
- Identity. The experiment directory is
<name>-<hash>;stages,output_dirandtrackerare not part of the hash, because they say how to run, not what to build. - Progress. The CLI shows two bars (stages, and steps inside the running stage); API jobs report the same
state in
progress. Every long phase names itself there: loading the model, checking lengths, a method's preparation (reference margins, reasoning chains), the steps, fitting the temperature, saving. - Lengths. Before the first training step every training and held-out row is measured against
training.max_length, including the tokens a method adds later (generated completions, reasoning chains). A row that does not fit stops the run there (training.overflow: error, the default) or is left out (overflow: skip); nothing is cut silently and nothing fails hours into training. Embedding texts are the exception: they are truncated, as usual for retrieval. - Evaluation while training.
training.eval_every: Nmeasures the model on the held-out rows every N steps with the validate stage's own metrics (training.eval_rowscaps how many rows), and records them aseval/<metric>next to the loss intraining.jsonland in the tracker. The weights are as they are at that step: for the decision recipes the temperature is not fitted yet, sonllandecethere are uncalibrated. - Validation. In the Modeling channel
validatefollowstrainand cannot be left out. It measures the trained model on data training never saw and checksvalidation.min/validation.max; a miss, or a metric that is not a finite number, stops the pipeline. A later stage reads the checkpoint fromvalidate, never fromtrain. Without bounds the metrics are only recorded, and a warning says so. - Tracking. With
tracker.enabledeach training step is sent to wandb as it happens; a run interrupted and resumed continues the same wandb run.trackeris not part of an experiment's identity, so switching it on later replays the already cached run once instead of retraining. A run is sent to a given project only once. - Jobs. The API keeps its jobs in a SQLite table (
JOBS_DB), so they survive a restart; a job the server was running when it stopped is markedinterruptedand resumes when submitted again.DELETE /api/jobs/<id>cancels a pending or running job: it stops at its next progress report, keeps what it built, and continues from there when submitted again.JOB_WORKERSjobs run at once, each worker on its own accelerator: set it to the number of GPUs on a multi-GPU server and keep 1 otherwise. On a server others can reach, setAPI_TOKEN(every/apicall then needsAuthorization: Bearer <token>) andAPI_PATHS(the directories the API may read configs and rows from and write output to; any other path is refused). - Typos. Every config section rejects keys it does not know, so
validaton:ortraining.learning_rateis an error instead of a silently ignored setting.
| Stage | What it does |
|---|---|
data |
read every data.sources[*], check each row against the recipe's method, hold out data.validation of them |
train |
load the backbone, run the training loop with the method's loss; resumes from resume.pt after a crash |
validate |
the method's metrics on the held-out rows against validation.min/max |
rows, score, report |
evaluation_custom: read and check the rows, measure the chosen checkpoint with the experiment's method, write report.json |
score:<task>..., report |
evaluation_benchmark: one lm-evaluation-harness run per benchmark (fingerprint: checkpoint content, task, few-shot, limit, decoding, harness version), then one report |
score:<run>:<task>..., rows, rows:<run>..., report |
evaluation_compare: the stages above per run, shared with the single-model recipes; the report compares every run with the base |
rows, score, report |
evaluation_decision: every question through the head without reasoning and after greedy reasoning; items.jsonl per question |
score:<task>..., report |
evaluation_embedding: one mteb run per task (fingerprint: checkpoint content, task, max_length, mteb version) |
clips, traces, report |
evaluation_wakeword: recordings and ambient WAVs found; the detector's score every 80 ms on all of them under each condition; the threshold sweep and the operating point |
data, train, validate |
tts_piper: recordings found and split; piper.train fit from the starting checkpoint, exported to voice.onnx; the voice reads the held-out texts and the fixed replies, Whisper transcribes them |
data, features, train, validate |
wakeword: recordings of both classes found and split; noisy windows as embeddings; the detector trained and exported; held-out streams scored every 80 ms |
| Recipe | Row keys | Metrics for validation.min/max |
|---|---|---|
llm_sft |
messages |
loss, perplexity |
llm_instruction |
instruction, input?, output |
loss, perplexity |
llm_dpo |
prompt, chosen, rejected |
accuracy (chosen more likely than rejected), margin |
llm_grpo |
prompt + what the reward reads (answer) |
reward (greedy completion) |
llm_decision_sft |
state, questions (Jev records) or state, question, label (one question per row) |
accuracy, nll, ece |
llm_decision_cispo |
same | accuracy, nll, ece, think_accuracy (after reasoning) |
embedding_contrastive |
query, positive, negative? |
accuracy (positive ranked first), mrr |
tts_piper |
audio (a WAV file), text |
wer (Whisper on the voice), reference_wer (Whisper on the recordings), duration_ratio, phrase_wer (the worst-heard fixed reply) |
wakeword |
audio, text? (a positive's must be the phrase) |
recall, false_accepts, scenario (phrase then command: one firing, on the phrase) |
By default every weight is trained, in FP32. training.adapter switches any of these recipes to LoRA:
training:
adapter: {name: lora, r: 16, alpha: 32, dropout: 0.05} # omit to train every weightThe base weights stay frozen and only the adapter trains, so the resume snapshot holds just the adapter. Which
layers are adapted comes from the architecture (transformer, bi_encoder: the attention projections; hybrid:
those plus the linear-attention projections) unless targets names others. The adapter is folded into the base
weights when the checkpoint is written, so a LoRA checkpoint and a fully trained one load the same way: a plain
save_pretrained directory at output/<experiment>/train/checkpoint/ (and, once validation passed, at
output/<experiment>/validate/checkpoint).
A decision model answers choice, noul (yes/no) and score questions about a state with a probability per
option. The backbone gets a head (model.head) that reads the options, and the prompt marks state, question and
options with rare tokens of the Qwen tokenizer:
| Head | How it scores | Origin |
|---|---|---|
readout |
options get one-token codes (A, B, ...); a linear layer on the decide position scores the codes | Jev, jeff |
pointer |
a dot product between the decide position and each option's end position; no codes, any number of options | Jeeves |
llm_decision_sft trains the head with cross-entropy over the options. With method.think_fraction: 0 that is a
one-pass decision model. Above 0, that share of the questions gets a reasoning chain sampled from the base model
before training and inserted as context, so the head learns to decide after reasoning as well as without it.
llm_decision_cispo continues from such a checkpoint (model.init): per question it samples group_size
reasoning chains, rewards each by the probability the head then gives the right option, and reinforces the chains
that beat their group's mean. The head's loss after reasoning and without reasoning is added, so the one-pass
answer keeps working.
Both keep method.calibration of the training records aside, fit one temperature on them when training ends and
store it with the head (head.json in the checkpoint); validation reports calibrated accuracy, nll and ece.
The training unit is the question: training.batch_size counts questions, however many a record holds, and CISPO
forwards batch_size x group_size sequences per step.
model:
head: pointer
init: output/decision-pointer-qwen3.5-0.8b-<hash>/validate/checkpoint # stage 2 starts where stage 1 endedmodel.init works in every llm_* and embedding_* recipe: it is "start from this checkpoint of an earlier
experiment". Point it at <experiment>/validate/checkpoint: that link exists only when the earlier experiment
passed validation, whereas train/checkpoint is there even when it failed. The checkpoint counts by content in
the fingerprint, so rebuilding the earlier experiment rebuilds the one that continues it.
tts_piper fine-tunes a Piper voice: a VITS model that reads espeak-ng
phonemes and runs anywhere as one ONNX file. Piper is GPL-3.0-or-later and pins its own torch, so it is not a
dependency of this package: it lives in environment/piper/ (its own pyproject.toml and uv.lock), and both speech
recipes run it as a separate process through PIPER_PYTHON. Build it once, and again after pulling: the script is
safe to repeat.
environment/piper/setup.sh # uv sync of the locked environment, plus the monotonic_align extension the wheel lacksmodel:
quality: medium # must match the starting checkpoint; high sets Piper's larger decoder
espeak_voice: en-gb-x-rp
init: # a Piper .ckpt on the Hub (pinned), a local .ckpt, or <experiment>/validate/checkpoint
name: jgkawell/jarvis
revision: 37f8763122312665f091d1fc760abaf1f79b02cc
file: en/en_GB/jarvis/medium/checkpoints/epoch=8338-step=2012650.ckpt
data:
sources:
- {name: delimited, path: recordings/metadata.csv, audio_dir: recordings/wavs} # `<file>|<text>` lines
training: {epochs: 100, batch_size: 16, args: {model.learning_rate: 1.0e-4}} # args: any other `piper.train fit` option
validation: {asr: {language: en}, max: {wer: 0.15}}- Rows.
audioandtext. A relativeaudiopath starts at the source'saudio_dir, else at the directory of the source file, and an LJSpeech id without.wavfinds its file.delimitedreads a headerless|file such as an LJSpeech or Pipermetadata.csv;local_jsonlreads therows.jsonlthatdata_speechwrites. The audio directories count in thedatafingerprint, so re-recorded files rebuild the experiment. - Training. The starting weights are copied in (
model.warmstart_ckpt: every tensor whose shape matches, a fresh optimizer), so an old Piper checkpoint loads into the current trainer andtraining.epochscounts from zero. Piper's own validation split is off; thevalidatestage measures on this recipe's held-out rows instead. A rerun after a crash continues from Piper's last epoch snapshot (run/lightning_logs/version_<n>/checkpoints/last.ckpt); Piper turns cuDNN determinism off, so a GPU run is not bit-identical to an earlier one.fit.logholds Piper's output; a cancelled API job stops when the fit ends. - Checkpoint.
<experiment>/validate/checkpoint/holdsvoice.onnxandvoice.onnx.json(a Piper voice:piper -m voice.onnx, Home Assistant's/share/piper) andmodel.ckpt, the start of a further fine-tune. - Validation. The voice reads up to
validation.rowsheld-out texts; Whisper (validation.asr, pinned) transcribes them and the original recordings.reference_weris the floorwercan reach with that recognizer; numbers written as digits in one text and as words in the other count as errors. - Fixed replies.
validation.phrases(name: text) are the lines the agent plays as they are, such as the read-back before a task (web_search: Searching Reference.). Piper's noise makes every take of a text different, so each is spokenvalidation.phrase_takestimes (default 3) and the take Whisper hears best becomes<experiment>/validate/phrases/<name>.wav;phrases.jsonlists every take and what was heard, andphrase_weris the worst kept take, so a voice that garbles one reply fails validation. Changing a reply reruns onlyvalidate; a rerun keeps the takes it has, so the agent plays the file that passed.
Before training on a voice, read its terms. jgkawell/jarvis is MIT on its weights per its model card, but it imitates
a film character played by a real actor; MIT does not cover the voice, so treat it as personal and non-commercial.
wakeword trains the detector that wakes a voice agent: a small model that listens to the microphone all the time
and fires when it hears one phrase. It follows openWakeWord (Apache-2.0):
audio becomes a melspectrogram and then a 96-number speech embedding every 80 ms through two frozen ONNX models
(pinned by hash, downloaded once), and a small dnn head scores the last 16 embeddings (2 s). Only the head is
trained, so a few hundred synthetic recordings are enough, and the exported file loads in openWakeWord's runtime.
phrase: Jarvis # the wake word: the agent's name; what follows it is the command
data:
positive: [{name: local_jsonl, path: output/wake-phrase-<hash>/select/rows.jsonl}] # data/wake_phrase.yaml
negative: [{name: local_jsonl, path: output/wake-negatives-<hash>/select/rows.jsonl}, # data/wake_negatives.yaml
{name: local_jsonl, path: output/wake-nearmiss-<hash>/select/rows.jsonl}] # data/wake_nearmiss.yaml
background: [] # directories of noise WAVs; empty: Gaussian noise
augment: {rounds: 4, followed: 2, noise: 200, snr: [5, 25]} # followed: "Jarvis" + a command, as negatives
validation: {threshold: 0.5, min: {recall: 0.9, scenario: 0.9}, max: {false_accepts: 0.05}}- Phrase. The wake word is the agent's name; the command after it ("Jarvis, wake up", "Jarvis, search the web
for ...") is not part of it, and speech recognition hears it once the detector has fired. Positives say the phrase
and nothing else (a positive row with a
textmust say it); negatives must never contain it, and thedatastage stops on one that does. The hardest negatives are near misses ("Travis", "Davis", "Karen"), and the commands said alone. The model file is named after the phrase:jarvis.onnx. - Recordings.
data_speechwith a multi-speaker voice (voice.speakers) andselect.per_text: nullmakes them: the name read by hundreds of speakers, each checked by Whisper. Recordings of the people who will use the agent (delimitedorlocal_jsonl) belong inpositivetoo: synthetic voices are a start, not the target. - Windows. Every training recording becomes
augment.rounds2-second windows over noise at a random SNR and gain, ending up toaugment.tailseconds after the speech. Negatives are also cut short at random, so half a phrase is a negative;augment.followedwindows per phrase put other speech after it, so the detector fires as the name ends and not again while the command that follows is spoken. All randomness comes from the seed and the recording. - Training. Balanced batches (half positive), BCE, Adam,
training.stepssteps on the CPU, seeded: a rerun after a crash trains the same detector again. - Validation. Held-out recordings in noise (
validation.snr), scored every 80 ms as the runtime scores them, a firing counted at most once a second:recallandfalse_acceptsper recording, andscenario, the agent's first moment: "Jarvis", a pause, then another held-out utterance (a command such as "Please search to the paper reference on the web", a near miss, or ordinary speech). It passes when the detector fires exactly once, while or just after the name is said. - Checkpoint.
<experiment>/validate/checkpoint/holdsjarvis.onnx, the two feature models andwakeword.json(the phrase, the files, the feature models' hashes):
from openwakeword.model import Model # pip install openwakeword
detector = Model(wakeword_models=["checkpoint/jarvis.onnx"], inference_framework="onnx",
melspec_model_path="checkpoint/melspectrogram.onnx", embedding_model_path="checkpoint/embedding_model.onnx")
score = detector.predict(chunk)["jarvis"] # chunk: 1280 samples, 16 kHz, int16; wake at >= the thresholdfalse_accepts is measured on the negatives you give, not on hours of real audio; openWakeWord's own models add a
large corpus of everyday speech and music for that. Add real background recordings and your own speech before
trusting the rate. openWakeWord's code is Apache-2.0 and its README puts its pre-trained models under CC BY-NC-SA 4.0;
the two feature models are derived from Google's Apache-2.0 speech_embedding, so treat a detector as
non-commercial until you have checked their terms.
The four configs below build what a voice agent says and hears; what it decides stays in the agent.
| Moment | What runs | Built by |
|---|---|---|
| User: "Jarvis, wake up" | the wake word detector fires as "Jarvis" ends, once | wakeword (wakeword/jarvis.yaml) |
speech to text hears the command after it ("wake up"), and the agent's model picks the command wake_up |
the agent (an LLM recipe can tune it) | |
| Agent: ready | plays the read-back of wake_up, phrases/wake_up.wav |
tts_piper validation.phrases |
| User: "Please search to the paper reference on the web" | speech to text, then the agent's model picks web_search |
the agent |
| Agent: "Searching Reference.", then works | plays phrases/web_search.wav, then runs the search |
tts_piper validation.phrases |
tts/piper_jarvis.yaml holds nine read-backs: wake_up (At your service, sir.), web_search (Searching Reference.),
horizontal_task (Processing Long Task.), remember_pattern (Checking the Pattern.), analysis (Target.
Analysis.: a full stop so the voice pauses), focus_mode_enter (Activate Focus mode.), focus_mode_quit (Deactivate Focus mode.), persona_karen
(Change to KAREN.) and persona_jarvis (Change to JARVIS.). The voice's training set (data/speech.yaml) reads them
too. Which utterance means which command is the agent's to decide: no recipe here maps "enter the focus mode" to
focus_mode_enter yet.
uv sync # Python 3.12
cp environment/.env.sample environment/.env # HF_TOKEN for gated models; API_TOKEN, API_PATHS for a shared server
uv run llmclick recipes # recipes, their stages and registry keys
uv run llmclick validate configs/llm/sft.yaml # experiment key and stage plan
uv run llmclick run configs/llm/sft.yaml # the stages the config asks for
uv run llmclick serve --host 0.0.0.0 --port 8000 # API + Swagger at /docs (or: uvicorn core.api.app:app)
curl -X POST localhost:8000/api/jobs -H 'content-type: application/json' \
-d '{"config_path": "configs/llm/sft.yaml"}'| Config | What it builds |
|---|---|
llm/sft.yaml |
SFT of Qwen3.5-0.8B (hybrid) on conversations |
llm/sft_transformer.yaml |
the same recipe on Qwen3-0.6B (transformer) |
llm/instruction.yaml |
instruction tuning of Qwen3.5-0.8B |
llm/dpo.yaml |
DPO of Qwen3.5-0.8B on preference pairs |
llm/sft_lora.yaml |
the SFT recipe with a LoRA adapter instead of full fine-tuning |
llm/grpo.yaml |
GRPO of Qwen3.5-0.8B with the exact_match reward |
llm/grpo_gsm8k.yaml |
GRPO on GSM8K word problems from the Hub with the last_number reward |
llm/decision_readout.yaml |
one-pass decision model: full fine-tuning, readout head (Jev, jeff) |
llm/decision_pointer.yaml |
reasoning decision model, stage 1: LoRA, pointer head, reasoning chains as context (Jeeves SFT) |
llm/decision_cispo.yaml |
reasoning decision model, stage 2: CISPO from the stage-1 checkpoint |
embedding/contrastive.yaml |
contrastive learning of Qwen3-Embedding-0.6B |
evaluation/custom.yaml |
the llm/sft.yaml experiment's validated checkpoint scored on the sample rows |
evaluation/benchmark.yaml |
the same checkpoint on the small_general preset plus KMMLU, 100 items each (needs uv sync --extra eval) |
evaluation/compare.yaml |
base model against the trained checkpoint on the preset and the sample rows, with a four-axis chart |
evaluation/decision.yaml |
the llm/decision_pointer.yaml checkpoint on the JevBench tiers, without and with reasoning |
evaluation/embedding.yaml |
the embedding/contrastive.yaml checkpoint on three MTEB tasks (needs uv sync --extra eval) |
data/synthetic.yaml |
Korean conversation rows for llm_sft from four topic seeds through a local Ollama model, evolved, judged, deduplicated |
data/speech.yaml |
a Piper training set: assistant lines written by a local Ollama model, read by the jgkawell/jarvis voice, checked by Whisper (needs environment/piper/setup.sh) |
tts/piper_jarvis.yaml |
fine-tuning from the jgkawell/jarvis medium checkpoint on that set, with the agent's nine read-backs; replace <hash> with the data/speech.yaml experiment's |
data/wake_phrase.yaml |
"Jarvis." (statement, question, call) read by 300 speakers of the libritts_r Piper voice, kept if Whisper hears it exactly |
data/wake_negatives.yaml |
near misses of the name, the commands said alone, and ordinary lines, read by 40 speakers of the same voice |
data/wake_nearmiss.yaml |
20 near misses of the name ("Travis", "Jars", "Jarrett", ...) by 120 more speakers, kept without a listening check |
wakeword/jarvis.yaml |
the "Jarvis" wake word detector from those three sets; replace each <hash> |
The llm/ and embedding/ configs ship as pilots: sample rows from samples/ and max_steps: 4. Point
data.sources at your rows and remove max_steps for a real run.
Docker: IMAGE_TAG=$(git rev-parse --short HEAD) docker compose -f environment/docker-compose.yml up -d --build.
Installed as a package (uv build, then pip install dist/llmclick-*.whl), llmclick runs from any directory:
config paths, output_dir and environment/.env are relative to the working directory, and LLMCLICK_ENV names a
different settings file.
Training and validation run on MPS; device in the config (or auto-detection: cuda, then mps, then cpu) decides.
Weights are trained in FP32: fully fine-tuning Qwen3.5-0.8B needs roughly 13 GB of unified memory at the shipped
batch sizes, and training.adapter (LoRA) cuts that to little more than the weights themselves.
Each family is a model catalog crossed with training methods; the two grow independently.
modeling
├── llm
│ ├── Model Catalog transformer, hybrid <- add an architecture
│ │ └── heads readout, pointer <- add a decision head
│ └── Training Method sft, instruction, dpo, grpo <- add a method
│ └── decision sft, cispo
├── embedding
│ ├── Model Catalog bi_encoder
│ └── Training Method contrastive
├── tts one recipe, trained by the Piper program; `model.quality` picks its size
└── wakeword one recipe: openWakeWord's dnn head on its frozen features
Subclass the family's backbone and register it; every recipe of the family can then name it in model.architecture.
@LLM_BACKBONES.register("moe") # modeling/llm/models/moe.py; import it in models/__init__.py
class MoEBackbone(LLMBackbone):
loader = AutoModelForCausalLM # the transformers class that builds it
frozen = ("router",) # parameter-name fragments training must not update
adapter_targets = ("q_proj", "v_proj") # layers LoRA adapts by default
markers = Markers(...) # only for decision recipes: seven single tokens of its tokenizerAn embedding architecture (for example a cross-encoder) subclasses EmbeddingBackbone the same way and overrides
what differs, such as pool.
A head owns the prompt it reads and turns hidden states into one score per option. The tokens that mark state,
question and options come from the architecture (LLMBackbone.markers), so a head works on any backbone that
defines them.
@HEADS.register("my_head") # modeling/llm/models/heads/my_head.py; import it in heads/__init__.py
class MyHead(DecisionHead):
def option_block(self, options): ... # how the options appear in the prompt
def scores(self, hidden, layouts): ... # (rows, options) scores from (rows, tokens, hidden) statesA method says what a row must contain, how a batch becomes a loss and how the result is measured. Then one config class and one registered pipeline make it a recipe.
class MyMethod(LLMMethod): # modeling/llm/methods/my_method.py
class Config(BaseModel): # the YAML's `method:` section
strength: float = 1.0
def check(self, row): ... # raise ValueError for a row this method cannot use
def loss(self, backbone, rows): ... # differentiable scalar for one batch
def evaluate(self, backbone, rows, batch_size, progress): ... # metrics the validation bounds can name
class MyConfig(LLMConfig): # modeling/llm/config.py
method_class = MyMethod
method: MyMethod.Config = Field(default_factory=MyMethod.Config)
@recipe # modeling/llm/pipeline.py
class MyPipeline(LLMPipeline):
kind = "llm_my_method"
config_class = MyConfigprepare(backbone, rows, workdir) runs once before training with the untouched base weights (DPO caches its
reference margins there). Helpers shared by LLM methods live in modeling/llm/methods/base.py.
A package with models/, methods/, a TuningConfig subclass that sets backbones to its catalog, and a
TuningPipeline subclass; import it in modeling/__init__.py. The data, train and validate stages and the training
loop are inherited.
Sampling is the expensive part of both methods. method.iterations takes that many optimizer steps on each sampled
group before sampling again. From the second step on the policy has moved away from the one that produced the
samples, and method.clip bounds how far: GRPO clips the policy ratio to 1 +- clip (PPO's surrogate), CISPO caps the
importance weight of each reasoning token at 1 + clip. llm_grpo also takes method.beta, a KL penalty towards
the base model; it needs training.adapter, because the base model is then the same weights with the adapter
switched off and no second model is loaded.
method: {group_size: 4, iterations: 2, clip: 0.2, beta: 0.05}
training: {adapter: {name: lora, r: 16, alpha: 32}}@REWARDS.register("json_valid") # modeling/llm/methods/grpo.py
def json_valid(completion: str, row: dict, params: dict) -> float: ...When training does not fit the shared loop (for example an external trainer run as a child process), a recipe
brings its own config class, stages and pipeline class; users only see a new value for pipeline.recipe.
class MyConfig(ModelingConfig): # modeling/<recipe>/config.py
model: MyModelConfig
class MyTrain(TrainStage[MyConfig]): # modeling/<recipe>/stages.py
sections = ("seed", "model") # config sections that decide the result
def train(self, workdir, inputs): # write into workdir; must be safe to run again after a crash
...
return {"checkpoint": str(workdir / "model")}
class MyValidate(ValidateStage[MyConfig]):
def measure(self, workdir, inputs): # metrics on held-out data; bounds and report are handled for you
return {"accuracy": ...}
@recipe # modeling/<recipe>/pipeline.py; import it in modeling/__init__.py
class MyPipeline(ModelingPipeline[MyConfig]):
kind = "my_recipe"
config_class = MyConfig
stage_classes = (MyTrain, MyValidate) # execution order; a stage may only require earlier onesInside a stage, self.progress.update(done, total, note) drives the progress bar.
A data, evaluation or modeling recipe is safe to rerun because its stages write through these. Use them rather than writing the step again, so the guarantee holds in one place:
| Need | Use | Where |
|---|---|---|
| Write a file whole or not at all | with atomic_path(path) as temporary: |
core/utils/files.py |
| Build a checkpoint directory whole or not at all | with staged_directory(workdir / "checkpoint") as staging: |
core/utils/files.py |
| Rows between stages | read_jsonl, write_jsonl (atomic); the data channel's read_rows also reads a missing file as empty |
core/utils/files.py, data/rows.py |
| Per-item results that survive a crash | cached(directory, key), keep(directory, key, row) under a natural key(...) |
data/rows.py |
| Hold out validation rows | held_out_positions(count, seed, share): a row's side depends only on the seed and its position |
modeling/stages.py |
| Load a model for one block | with backbone.loaded(device, checkpoint): releases it afterwards, also on failure |
modeling/tuning/backbone.py |
A name: key in the config |
a Registry; in the config validator REGISTRY.check(*names) gives the standard error |
core/registry.py |
| Local files a config reads | local_paths(entries, keys) in paths(), so the API can confine them |
modeling/config.py |
| Recordings named by rows | audio.recordings(source), audio.signatures(sources) for the fingerprint |
modeling/tts/audio.py |
uv run pytest tests/core tests/modeling # runner, progress, validation gate, API, every recipe on a tiny random model (wakeword: a fake front end)
uv run pytest tests/data # data recipes with fake teachers, a fake Piper and a fake Whisper
uv run ruff check . && uv run mypy # what CI runs on every pull request, besides the testsLicence: MIT. Models and datasets a config names keep their own licences.