Plugin System (v0.45.0)

5 parts, +169 tests. Public plugin / hook surface.

BasePlugin Protocol

python
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
# discovered by subclassing: BasePlugin is a runtime-checkable
# Protocol, not a base class you inherit registration from. Something
# has to call register_plugin(...): your own code, or the entry point
# described under "Discovery and opt-in" below.
register_plugin(
    name="my-trainer",          # kebab-case
    version="0.1.0",            # semver
    plugin=MyTrainerPlugin(),
    description="Example",
)

Discovery and opt-in (v0.76.0)

Soup discovers installed Python distributions that publish the soup_cli.plugins entry-point group (and any module under soup_cli/plugins/, of which none ship in the wheel). Discovery reads package metadata only: a third-party entry point is listed as disabled, with the description "Installed entry point; enable it to load third-party code.", without its module being imported or executed. soup plugins enable <name> is the explicit code-loading boundary: it imports the entry point, requires it to register a plugin with the same name as the entry point, and stores the choice atomically in ~/.soup/plugins.json (a version-1 JSON file of at most 64 KiB, written with mode 0600 where the platform supports it; a symlink at that path is refused). Later soup processes reuse that choice, so once you have enabled a plugin, a training run on a trainer that attaches the plugin callback imports it and fires its hooks, without any further wiring in your training config.

toml
[project.entry-points."soup_cli.plugins"]
demo-plugin = "demo_plugin:register"

The target is a zero-argument registration function (a module that registers on import also works). The entry-point name must match the plugin name that register_plugin(...) registers (kebab-case, 1 to 40 characters), or soup plugins enable fails with failed to enable plugin 'demo-plugin': <ExceptionType> and leaves nothing half-loaded. The registry holds at most 64 plugins.

bash
soup plugins list              # discovers and lists; a third-party entry point shows as disabled
soup plugins enable demo-plugin
soup plugins disable demo-plugin

With nothing installed soup plugins list prints No plugins registered. and exits 0. The table has name, version, state, hooks, resources and description columns (a plugin may also register templates and model_groups; they are descriptive metadata that soup plugins shows, and Soup does not apply them to a model configuration). Exit codes: enable and disable exit 1 for Unknown plugin: <name> and 2 when the name is not kebab-case, the choice cannot be saved or the entry point fails to load (an unwritable state directory, a symlinked state file, a registration function that raises). soup plugins install <name> exits 2 and installs nothing: Soup does not install plugin <name>. Install a trusted Python distribution that exposes the soup_cli.plugins entry-point group, then opt in with soup plugins enable <name>. Through v0.75.2 the same command printed an advisory and exited 0, which was a break for any script that ran it; install the distribution with pip yourself, then enable it. The list, enable, disable, install and unknown-plugin outputs above were run on the v0.76.0 CLI against a stand-in distribution (a .dist-info directory on PYTHONPATH).

Three limits worth knowing:

  • Enabling a plugin runs its code with your privileges. Soup does not sandbox, vet or verify a plugin; enable is an import. Install only a distribution you trust.
  • The opt-in lives in a file. Anything that can write ~/.soup/plugins.json (or the file SOUP_PLUGIN_STATE_PATH points at) can enable an installed entry point, and the next soup plugins command or training run that builds the plugin callback will import it. An unreadable, oversized or malformed state file is ignored with a warning and every third-party plugin stays disabled. SOUP_PLUGIN_STATE_PATH accepts any path up to 4,096 characters and is not held to your home or working directory; upstream's source describes it as mainly there for tests, so see Environment variables before relying on it.
  • A hook that raises is swallowed. See below.

Registered, enabled plugins really run, inside the process that registered or enabled 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 and MoLE-routing trainers and the MLX backend do not. The TTS trainer subclasses the SFT trainer and inherits its setup, so it does attach them. 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.

Discovery happens when you run soup plugins, and again when a training run builds the plugin callback (the callback calls the same discovery before it snapshots the enabled hooks). If you register a plugin from your own Python entry point, that still works exactly as before, in the same process, and it needs no enable.

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.76.0 (v0.75.2 neither), 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.

bash
soup data recipe recipe.yaml

Validation 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. Since v0.76.0 a recipe that contains llm_text or judge nodes also needs a choice between a real provider and placeholder output (the example below has both kinds of node, so it needs one of them):

bash
soup data recipe recipe.yaml --execute --output ./out --provider ollama
soup data recipe recipe.yaml --execute --output ./out --offline
yaml
nodes:
  - {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]

llm_text and judge nodes need --provider or --offline. --provider ollama, anthropic or vllm runs those nodes against a live provider (--model defaults to llama3.1; --base-url applies to Ollama and vLLM and defaults to loopback); the finished line then reports N provider calls, M failures., and a node whose every provider call fails stops the command with exit 1, naming the endpoint, while a partial run keeps the rows that succeeded. Without --provider and without --offline the command refuses with exit 2: This recipe contains llm_text or judge nodes. Pass --provider <ollama|anthropic|vllm>, or explicitly opt in to placeholder output with --offline. --offline is the explicit opt-in to the old behaviour: llm_text writes llm_text(offline): <rendered prompt> into each row and judge marks every row true, after a yellow Offline recipe mode note. --provider together with --offline, and --model or --base-url without --provider, exit 2, as does an unknown provider. The --offline run was reproduced on the v0.76.0 CLI with the recipe above and wrote out/final.jsonl; the provider path was not run here (it needs a model server). Through v0.75.2 the CLI passed no provider and always wrote the placeholder rows, silently. seed, code, validator and sampler need neither flag.

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.