Plugin System (v0.45.0)
5 parts, +169 tests. Public plugin / hook surface.
BasePlugin Protocol
from soup_cli.plugins import register_plugin
# The four hook names are fixed: pre_train, post_train, pre_step, post_step.
# Each takes one context dict and returns None. A plugin carries any
# subset of them; hooks are discovered by name, so a misspelled one is
# silently never called.
class MyTrainerPlugin:
def pre_train(self, context): ...
def post_step(self, context): ...
# Registration is what puts a plugin in the registry. Nothing is
# auto-discovered by subclassing: BasePlugin is a runtime-checkable
# Protocol, not a base class you inherit registration from.
register_plugin(
name="my-trainer", # kebab-case
version="0.1.0", # semver
plugin=MyTrainerPlugin(),
description="Example",
)The registry lives in the process that calls register_plugin(...). soup plugins list, enable and disable run in a process of their own, so they only see plugins that process registered, which for a stock soup command is none: soup plugins list prints No plugins registered., and soup plugins enable my-trainer exits 1 with Unknown plugin: my-trainer; soup plugins disable my-trainer fails the same way. soup plugins install <name> prints an advisory that live install lands in a later release, which never shipped, and installs nothing.
Registered plugins really run, inside the process that registered them. The SFT, pretrain, DPO, IPO, ORPO, SimPO, KTO, BCO, online DPO, GRPO, PPO, reward-model, embedding and distill trainers (and task: preference, which hands off to the paired-loss ones) attach them as a genuine Hugging Face TrainerCallback; the ASR, classifier, PRM, unlearn, MoLE-routing and TTS trainers and the MLX backend do not. On the trainers that do, pre_train / post_train / pre_step / post_step fire against the live training loop rather than being a surface waiting to be wired. Two deliberate properties: a hook that raises is swallowed at WARNING, so one misbehaving plugin cannot kill a multi-hour run, and the hook set is snapshotted when the callback is constructed, so registering a plugin mid-run does not retroactively receive events.
One thing that is not automatic: nothing is auto-discovered at startup. load_plugins() has no caller in the shipped code and there are no entry points, so soup train never imports a third-party plugin module. A plugin takes effect only when your own Python entry point calls register_plugin(...) and then runs the trainer in the same process.
OpenAI ↔ Anthropic Messages converter
anthropic_messages.py converts between OpenAI and Anthropic Messages schemas — useful for trace-to-preference + serve.
Server-side tools allowlist
server_tools.py ships a server-side tools allowlist + WebSearchConfig for the inference server.
N-gram speculative decoding
ngram_spec.py holds a validated schema for n-gram (prompt-lookup) speculative decoding, and the transformers generate path forwards prompt_lookup_num_tokens when it is handed a config. No CLI flag or soup.yaml key builds that config at v0.75.2, so soup serve cannot turn it on.
External integrations catalog
15-entry registry of known deployment and serving targets a trained model can be handed to, each with the artifact format it expects: LM Studio, ComfyUI, stable-diffusion.cpp, Open WebUI, Ollama, TEI, pgvector, FAISS, Weaviate and the rest. Not experiment trackers: W&B, MLflow, ClearML, Comet and Neptune are a separate allowlist, wired through soup train --tracker.
Advanced trainer-plugin allowlist
Closed allowlist for advanced trainer plugins.
Data Recipe DAG
recipe_dag.py parses a YAML DAG describing data preprocessing steps + dependencies. Topological sort guarantees deterministic execution order.
soup data recipe recipe.yamlValidation is the default: the path is a positional argument and there is no validate subcommand. The live per-node runner shipped in v0.53.7 behind --execute, which also needs --output <dir> under the current directory and checkpoints after each node:
soup data recipe recipe.yaml --execute --output ./outnodes:
- {name: seed1, kind: seed, config: {path: prompts.jsonl}}
- {name: llm1, kind: llm_text, config: {prompt: "Answer: {prompt}"}}
- {name: judge1, kind: judge}
- {name: final, kind: sampler}
edges:
- [seed1, llm1]
- [llm1, judge1]
- [judge1, final]From the CLI the llm_text and judge nodes are offline stubs. The command passes no provider, so llm_text writes llm_text(offline): <rendered prompt> into each row and judge marks every row true; the provider path exists only in the Python function run_recipe(..., judge_provider=...). Use seed, code, validator and sampler for real work.
The kinds are seed (config.path, a JSONL file), llm_text (config.prompt, formatted per row), code, judge, validator (config.regex, searched in config.field, default text, or a JSON Schema in config.schema, which needs the jsonschema package) and sampler, which writes the merged rows of its inputs to <output>/<node name>.jsonl and does no subsampling. Node names match [a-z0-9][a-z0-9_-]{0,63}. A recipe has at most 256 nodes and 1,024 edges and fits in 1 MiB; cycles, self-loops, duplicate edges and edges that name a missing node are rejected.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.