feat(v0.45.0): Plugin System & Ecosystem Wins — 5 Parts, +169 tests

Adds a public plugin/hook system plus the schema scaffolding for 20+ ecosystem
integrations. Live trainer-callbacks, Anthropic /v1/messages route, server-tool
HTTP endpoints, and the recipe runner ship in v0.45.1 (matches v0.27.0 MII /
v0.37.0 multipack / v0.41.0 LLaMA Pro stub-then-live pattern).

Part A — Plugin / hook system
* New soup_cli/plugins/ package: BasePlugin Protocol, PluginSpec frozen
  dataclass, register_plugin / discover_hooks / enable_plugin /
  disable_plugin / load_plugins. Kebab-case name regex, semver-ish version,
  null-byte rejection on every string. Idempotency check covers
  (version, plugin object, templates, model_groups, description) — review-fix
  added description after first-cut omitted it. Per-list caps on templates
  and model_groups (32 entries, 128-char per name).
* New soup plugins list/install/enable/disable Typer CLI; all user-controlled
  output passes through rich.markup.escape.

Part B — API extensions (schema-only)
* utils/anthropic_messages.py: to_anthropic / from_anthropic /
  validate_anthropic_payload converters. Multiple system messages join with
  \n\n; tool role with structured (list) content concatenated into single
  tool_result text block (review-fix MEDIUM — first-cut silently dropped).
  max_tokens cap 16384, temperature [0.0, 2.0], bool rejection on numerics.
* utils/server_tools.py: closed {python, bash, web_search} allowlist,
  WebSearchConfig with domain allowlist + leading-dot subdomain pattern,
  rate_limit [1, 600]. is_domain_allowed strips :port suffix and rejects
  IPv6 literals (review-fix MEDIUM).
* utils/ngram_spec.py: NgramSpecConfig validators with bounded n / draft
  tokens / prompt-lookup-max; bool rejection on every numeric field.

Part C — External integrations catalog
* utils/integrations.py: 15-entry MappingProxyType catalog of ecosystem
  targets (lm-studio, comfyui, ollama, claude-code, cursor, continue, ...).

Part D — Advanced trainer-plugin allowlist
* utils/trainer_plugins.py: 6-entry allowlist (grokfast, spectrum,
  llmcompressor, sonicmoe, cce_plugin, math_verify) + validate_trainer_
  plugin_list (Sequence[str], dedup, _MAX_PLUGINS_PER_RUN=8).

Part E — Data Recipe DAG
* utils/recipe_dag.py: closed NODE_KINDS frozenset, Kahn's topological
  sort via collections.deque (review-fix HIGH — first-cut had O(N^2 log N)
  queue.sort() inside the BFS body), cycle / self-loop / dangling-edge
  rejection, _MAX_NODES=256 / _MAX_EDGES=1024 / _MAX_FILE_BYTES=1MiB.
  load_recipe_yaml enforces is_under_cwd containment AND os.lstat + S_ISLNK
  symlink rejection (review-fix MEDIUM — TOCTOU defence; mirrors v0.33.0 #22
  / v0.43.0 Part C / v0.44.0 Part B policy).
* New soup data recipe <path> CLI validates topology and prints planned
  topo order; live runner deferred to v0.45.1.

Reviews: python-review, security-review, code-review, tdd-guide all run;
verification-loop replaced by manual smoke (CLI happy + failure paths
exercised on real fixtures).

Test count: 5820 -> 5989 (+169). Test files: 164 -> 165. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-10 21:21:04 +05:00
parent b4924b12a1
commit 74301843a2
16 changed files with 2793 additions and 15 deletions

View File

@ -111,7 +111,7 @@ soup_cli/
templates/ - 17 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (164 files, 5820 tests)
tests/ - Test suite (165 files, 5989 tests)
examples/ - Real-world config examples and datasets
```

132
README.md
View File

@ -43,15 +43,15 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.44.0 — Live Dashboard & UX**: Studio-grade observability + 13 ergonomics fixes + 7 new standalone CLIs. 21 features that close the polish gap with Unsloth Studio, axolotl, and LlamaFactory.
**v0.45.0 — Plugin System & Ecosystem Wins**: A public plugin API and the schema scaffolding for 20+ ecosystem integrations. Soup is now extensible.
- **`soup monitor` — live GPU panel.** Rich Live `nvidia-smi`-driven dashboard: Util / Mem / VRAM / Temp / Power per GPU. `--refresh 0.25-30` interval, `--once` for a single snapshot. Apple Silicon hint deferred to v0.44.1.
- **Standalone CLIs.** `soup fetch examples llama-3.1-8b-lora` writes a ready-to-edit YAML from the bundled catalog. `soup quantize <model> --to gguf --bits 4` prints the equivalent `soup export …` invocation. `soup merge-sharded-fsdp-weights` and `soup delinearize-llama4` ship as planners (live torch runtime in v0.44.1). `soup llama <subcommand>` proxies to llama.cpp binaries with a child-env allowlist that drops `HF_TOKEN` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`.
- **Ctrl+C graceful save.** First SIGINT writes a checkpoint and continues; second SIGINT stops training cleanly. Touch `<output_dir>/.checkpoint_now` to force an out-of-band save (cwd-contained, symlink-rejected at the trigger path).
- **Web UI plugin registry.** Drop-in `soup_cli/ui/plugins/*.py` files register tabs via `register_tab(name=…, title=…, render=…)` at import time. Tab name kebab-case allowlist, 32-tab cap, idempotent re-register. Plus `API_HOST` / `API_PORT` / `API_KEY` / `GRADIO_HOST` / `GRADIO_PORT` env knobs for the FastAPI + Gradio surfaces.
- **Tail-latency stats + tool-call timer.** `update_ema` / `percentile` / `summarise_latency` ship as pure-Python (used by `runs show` + the live dashboard). `ToolOutputsBuffer` is a thread-safe `collections.deque(maxlen=1000)` ring; `ToolCallTimer` context-manager records duration / output / error per tool invocation for tool-calling SFT runs.
- **Onboarding wizard helper.** `render_onboarding_yaml({base, dataset, task, quantization, epochs})` returns a complete validated `soup.yaml``output` field cwd-contained, Literal allowlists on `task` + `quantization`, `epochs ∈ [1, 10]`.
- **+192 net new tests** — covers all 21 features: GPU-CSV parser + DoS caps, SSE frame schema, QR token in query string (not fragment) with IPv6 bracketing, llama-server timings + KV bar, deque ring + concurrent writes, Ctrl+C SIGINT install/restore, sweep-config scalar allowlist + frozen `MappingProxyType`, fetch symlink + commonpath defence, llama child-env allowlist drops secrets, plus 5 review-fix coverage gaps closed.
- **Plugin / hook system.** `soup_cli.plugins.register_plugin(name, version, plugin, templates=[], model_groups=[])` lets third-party Python modules ship their own pre-train / post-train / pre-step / post-step hooks plus chat templates and model groups. Registry is idempotent on identical specs and rejects conflicting re-registration. Drop a file under `soup_cli/plugins/` and the loader picks it up at startup. New `soup plugins list / install / enable / disable` CLI.
- **Anthropic Messages API converter.** `to_anthropic` / `from_anthropic` translate between OpenAI chat-completions and Anthropic Messages payloads — multiple `system` messages joined with `\n\n`, `tool` role surfaces as `tool_result` content blocks (list content concatenated, never silently dropped), `max_tokens` capped at 16384, `temperature` bounded `[0.0, 2.0]`. Live `/v1/messages` endpoint deferred to v0.45.1.
- **Server-side tools allowlist.** Closed `{python, bash, web_search}` set with `WebSearchConfig` — domain allowlist (leading-dot subdomain pattern, port-strip on the host, IPv6 literal deny), `rate_limit_per_minute ∈ [1, 600]`. `python` and `bash` reuse the v0.25.0 RLVR sandbox; live HTTP endpoints deferred to v0.45.1.
- **External integrations catalog.** 15-entry frozen `MappingProxyType` of ecosystem targets — `lm-studio`, `comfyui`, `stable-diffusion-cpp`, `open-webui`, `ollama`, `tei`, `pgvector`, `faiss`, `weaviate`, `sentence-transformers`, `claude-code`, `cursor`, `continue`, `cline`, `sillytavern`. Each entry names the artifact format (`gguf` / `safetensors` / `served-endpoint`).
- **Advanced trainer-plugin allowlist.** 6-entry catalog (`grokfast` / `spectrum` / `llmcompressor` / `sonicmoe` / `cce_plugin` / `math_verify`) with `validate_trainer_plugin_list` (dedup, allowlist match, `_MAX_PLUGINS_PER_RUN=8`). Live callbacks land in v0.45.1.
- **`soup data recipe <recipe.yaml>`.** Validates a Seed → LLM Text → Code → Judge → Validator → Sampler graph DAG: closed node-kind allowlist, Kahn's topological sort with `collections.deque` (deterministic, O(N+E)), self-loop / cycle / dangling-edge / duplicate rejection, cwd containment + `os.lstat + S_ISLNK` symlink rejection on the recipe file. Live offline runner against a local model in v0.45.1.
- **+169 net new tests** — covers all 5 release Parts: plugin idempotency + per-list caps + description conflict rejection, Anthropic converter happy + failure paths, domain allowlist port-strip + IPv6 deny, n-gram bounds, integrations catalog immutability, trainer-plugin canonicalisation + dedup, recipe DAG cycle / cap / symlink + CLI happy / invalid / missing.
## Why Soup?
@ -3161,6 +3161,122 @@ params:
Strict scalar allowlist on values (`str` / `int` / `float` / `bool`); `_MAX_FILE_BYTES=256KB`, `_MAX_PARAM_KEYS=32`, `_MAX_VALUES_PER_KEY=64`; `SweepSpec.params` is `MappingProxyType[str, Tuple[Any, ...]]` for genuine immutability.
## Plugin System
Drop a Python module under `soup_cli/plugins/` (or any package importable by Soup) and register at import time:
```python
from soup_cli.plugins import register_plugin
class MyPlugin:
def pre_train(self, ctx):
...
def post_train(self, ctx):
...
register_plugin(
name="my-plugin",
version="1.0.0",
plugin=MyPlugin(),
description="Hooks into pre/post-train",
templates=["my-template"], # optional
model_groups=["my-arch-family"], # optional
)
```
```bash
soup plugins # list registered plugins
soup plugins enable foo
soup plugins disable foo
```
Plugin names are kebab-case (`^[a-z0-9][a-z0-9-]{0,39}$`); versions are semver-ish (`MAJOR.MINOR.PATCH`); registry caps `_MAX_PLUGINS=64`, `_MAX_TEMPLATES_PER_PLUGIN=32`, `_MAX_MODEL_GROUPS_PER_PLUGIN=32`. Re-registering the same `(name, version, plugin, templates, model_groups, description)` is idempotent; any field mismatch is rejected with a clear error. Trainer-callback wiring of `pre_train` / `post_train` / `pre_step` / `post_step` lands in v0.45.1.
## Anthropic Messages API Converter
Pure-Python converters between OpenAI chat-completions and Anthropic Messages payload shapes:
```python
from soup_cli.utils.anthropic_messages import to_anthropic, from_anthropic
anthropic_payload = to_anthropic({
"model": "claude-3-5-sonnet",
"messages": [
{"role": "system", "content": "you are helpful"},
{"role": "user", "content": "hi"},
],
"max_tokens": 256,
})
```
Multiple `system` messages join with `\n\n`. `tool` role with structured (list) content is concatenated into a single `tool_result` text block, never silently dropped. `max_tokens` capped at 16384, `temperature` bounded `[0.0, 2.0]`. Live `/v1/messages` endpoint inside `soup serve` lands in v0.45.1.
## Server-Side Tools
```python
from soup_cli.utils.server_tools import (
SUPPORTED_TOOLS, WebSearchConfig, is_domain_allowed, validate_web_search_config,
)
# SUPPORTED_TOOLS == frozenset({"python", "bash", "web_search"})
config = WebSearchConfig(
domain_allowlist=("example.com", ".docs.example.com"),
rate_limit_per_minute=30,
)
validate_web_search_config(config)
is_domain_allowed("a.docs.example.com:443", config.domain_allowlist) # True
is_domain_allowed("[::1]", config.domain_allowlist) # False
```
`python` and `bash` reuse the v0.25.0 RLVR sandbox; `web_search` is gated by an explicit domain allowlist (default empty = deny all). `is_domain_allowed` strips `:port` suffixes before matching and rejects IPv6 literals so `Host: api.example.com:443` matches `api.example.com`. Live HTTP tool endpoints in v0.45.1.
## External Integrations Catalog
```python
from soup_cli.utils.integrations import list_integrations, get_integration
list_integrations() # 15 entries
get_integration("lm-studio").target_artifacts # ("gguf",)
```
15 ecosystem targets covered: `lm-studio`, `comfyui`, `stable-diffusion-cpp`, `open-webui`, `ollama`, `tei`, `pgvector`, `faiss`, `weaviate`, `sentence-transformers`, `claude-code`, `cursor`, `continue`, `cline`, `sillytavern`. Auto-detect + launch wiring lands with v0.46.0 Deploy Autopilot.
## Advanced Trainer Plugins
```python
from soup_cli.utils.trainer_plugins import validate_trainer_plugin_list
validate_trainer_plugin_list(["grokfast", "spectrum"])
# returns ("grokfast", "spectrum") — canonical lowercase, dedup, ≤ 8 entries
```
6-entry allowlist (`cce_plugin`, `grokfast`, `spectrum`, `llmcompressor`, `sonicmoe`, `math_verify`) so a future `training.trainer_plugins: [...]` schema field has a stable surface. Live callbacks in v0.45.1.
## Data Recipe DAG
```bash
soup data recipe my_recipe.yaml
```
```yaml
nodes:
- name: seed1
kind: seed
config: {path: prompts.jsonl}
- name: llm1
kind: llm_text
- name: judge1
kind: judge
- name: samp1
kind: sampler
edges:
- [seed1, llm1]
- [llm1, judge1]
- [judge1, samp1]
```
Closed node-kind allowlist (`seed` / `llm_text` / `code` / `judge` / `validator` / `sampler`); Kahn's topological sort via `collections.deque` (deterministic, O(N+E)); cycle / self-loop / duplicate-edge / dangling-edge / unknown-kind rejection. `_MAX_NODES=256`, `_MAX_EDGES=1024`, `_MAX_FILE_BYTES=1MiB`. The recipe file must stay under cwd and **must not be a symlink** (`os.lstat + S_ISLNK` TOCTOU defence). Live offline runner against a local model lands in v0.45.1.
## Changelog
See [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases) for version history.

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.44.0"
version = "0.45.0"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.44.0"
__version__ = "0.45.0"

View File

@ -148,6 +148,15 @@ app.add_typer(
help="Proxy to llama.cpp binaries (cli / mtmd-cli / gguf-split / server).",
)
# v0.45.0 Part A — Plugin system CLI.
from soup_cli.commands import plugins as plugins_cmd # noqa: E402
app.add_typer(
plugins_cmd.app,
name="plugins",
help="List, enable, disable Soup plugins (v0.45.0).",
)
# Register data generate as a subcommand of data
data.app.command(name="generate")(generate.generate)

View File

@ -2149,3 +2149,34 @@ def demo_bundle(
console.print(
f"[green]Copied bundle '{bundle.name}' to[/] {_esc(written)}"
)
@app.command(name="recipe")
def recipe(
path: str = typer.Argument(..., help="Path to recipe.yaml under cwd"),
) -> None:
"""v0.45.0 Part E — Validate a Data Recipe DAG (live runner deferred)."""
from rich.markup import escape as _escape
from soup_cli.utils.recipe_dag import load_recipe_yaml
try:
dag = load_recipe_yaml(path)
except FileNotFoundError as exc:
console.print(f"[red]Recipe not found: {_escape(str(exc))}[/]")
raise typer.Exit(1) from exc
except (TypeError, ValueError) as exc:
console.print(f"[red]Invalid recipe: {_escape(str(exc))}[/]")
raise typer.Exit(2) from exc
console.print(
f"[green]Recipe validated.[/] {len(dag.nodes)} node(s), "
f"{len(dag.edges)} edge(s)."
)
console.print(
"Topological order: "
+ ", ".join(_escape(name) for name in dag.topo_order)
)
console.print(
"[yellow]Live runner deferred to v0.45.1.[/]"
)

View File

@ -0,0 +1,101 @@
"""v0.45.0 Part A — `soup plugins` CLI."""
from __future__ import annotations
import typer
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from soup_cli import plugins as plugins_pkg
app = typer.Typer(
name="plugins",
help="List, enable, disable Soup plugins.",
rich_markup_mode="rich",
no_args_is_help=False,
invoke_without_command=True,
)
console = Console()
@app.callback()
def _default(ctx: typer.Context) -> None:
"""When invoked with no subcommand, list registered plugins."""
if ctx.invoked_subcommand is None:
_show_table()
@app.command("list")
def list_cmd() -> None:
"""List all registered plugins."""
_show_table()
@app.command("install")
def install_cmd(name: str = typer.Argument(..., help="Plugin name")) -> None:
"""Install advisory — actual installation lives in v0.45.1."""
safe = escape(name)
console.print(
f"[yellow]Plugin install for [bold]{safe}[/] is advisory in v0.45.0; "
"live install lands in v0.45.1.[/]"
)
console.print(
"Drop your plugin module under [bold]soup_cli/plugins/[/] and call "
"[bold]register_plugin(...)[/] at import time."
)
@app.command("enable")
def enable_cmd(name: str = typer.Argument(..., help="Plugin name")) -> None:
safe = escape(name)
try:
changed = plugins_pkg.enable_plugin(name)
except KeyError:
console.print(f"[red]Unknown plugin: {safe}[/]")
raise typer.Exit(code=1)
except (TypeError, ValueError) as exc:
console.print(f"[red]{escape(str(exc))}[/]")
raise typer.Exit(code=2)
state = "enabled" if changed else "already enabled"
console.print(f"[green]Plugin {safe} {state}.[/]")
@app.command("disable")
def disable_cmd(name: str = typer.Argument(..., help="Plugin name")) -> None:
safe = escape(name)
try:
changed = plugins_pkg.disable_plugin(name)
except KeyError:
console.print(f"[red]Unknown plugin: {safe}[/]")
raise typer.Exit(code=1)
except (TypeError, ValueError) as exc:
console.print(f"[red]{escape(str(exc))}[/]")
raise typer.Exit(code=2)
state = "disabled" if changed else "already disabled"
console.print(f"[yellow]Plugin {safe} {state}.[/]")
def _show_table() -> None:
plugins_view = plugins_pkg.list_plugins()
if not plugins_view:
console.print("[dim]No plugins registered.[/]")
return
table = Table(title="Soup plugins")
table.add_column("name")
table.add_column("version")
table.add_column("state")
table.add_column("hooks")
table.add_column("description")
for name in sorted(plugins_view):
spec = plugins_view[name]
hooks = sorted(plugins_pkg.discover_hooks(spec.plugin).keys())
state = "[green]enabled[/]" if spec.enabled else "[yellow]disabled[/]"
table.add_row(
escape(spec.name),
escape(spec.version),
state,
", ".join(hooks) if hooks else "[dim]none[/]",
escape(spec.description),
)
console.print(table)

View File

@ -0,0 +1,318 @@
"""v0.45.0 Part A — Plugin / hook system.
Public API for third-party plugins. Plugins register themselves at module
import time via ``register_plugin(...)`` and provide hooks the trainer fires
at well-known points (``pre_train`` / ``post_train`` / ``pre_step`` /
``post_step``). Plugins may also register chat templates and model groups
via ``register_template`` / ``register_model_group``.
This release ships the registry and CLI surface; live trainer-callback
wiring lands in v0.45.1 (mirrors the v0.27.0 MII / v0.37.0 multipack
stub-then-live pattern).
"""
from __future__ import annotations
import importlib
import logging
import pkgutil
import re
from dataclasses import dataclass
from threading import RLock
from types import MappingProxyType
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
Tuple,
runtime_checkable,
)
logger = logging.getLogger(__name__)
# Plugin name: kebab-case, alphanumeric + hyphens; 1..40 chars, leading alnum.
_PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,39}$")
# Semver-ish: MAJOR.MINOR.PATCH with optional ``-tag`` / ``+build`` suffix.
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[\-+][A-Za-z0-9.\-]{1,32})?$")
_MAX_PLUGINS = 64
_MAX_DESCRIPTION = 256
_MAX_TEMPLATES_PER_PLUGIN = 32
_MAX_MODEL_GROUPS_PER_PLUGIN = 32
_MAX_NAME_ENTRY_LEN = 128
_HOOK_NAMES: Tuple[str, ...] = (
"pre_train",
"post_train",
"pre_step",
"post_step",
)
@runtime_checkable
class BasePlugin(Protocol):
"""Duck-typed plugin protocol.
Plugins are objects (instance or class) carrying any subset of the
four hook methods. Each hook accepts a single ``context`` argument
(an opaque dict the trainer fills in) and returns ``None``.
"""
def pre_train(self, context: Dict[str, Any]) -> None: ...
def post_train(self, context: Dict[str, Any]) -> None: ...
def pre_step(self, context: Dict[str, Any]) -> None: ...
def post_step(self, context: Dict[str, Any]) -> None: ...
@dataclass(frozen=True)
class PluginSpec:
"""One registered plugin."""
name: str
version: str
plugin: Any
description: str = ""
enabled: bool = True
templates: Tuple[str, ...] = ()
model_groups: Tuple[str, ...] = ()
_PLUGINS: Dict[str, PluginSpec] = {}
_LOCK = RLock()
def _validate_name(name: str) -> None:
if not isinstance(name, str):
raise TypeError("plugin name must be a string")
if not _PLUGIN_NAME_RE.match(name):
raise ValueError(
"plugin name must be kebab-case ([a-z0-9][a-z0-9-]{0,39})"
)
def _validate_version(version: str) -> None:
if not isinstance(version, str):
raise TypeError("plugin version must be a string")
if not _VERSION_RE.match(version):
raise ValueError(
"plugin version must match MAJOR.MINOR.PATCH (semver)"
)
def _validate_description(description: str) -> None:
if not isinstance(description, str):
raise TypeError("description must be a string")
if "\x00" in description:
raise ValueError("description must not contain null bytes")
if len(description) > _MAX_DESCRIPTION:
raise ValueError(f"description exceeds {_MAX_DESCRIPTION} chars")
def list_hook_names() -> Tuple[str, ...]:
"""Return the canonical hook names recognised by the trainer."""
return _HOOK_NAMES
def discover_hooks(plugin: Any) -> Dict[str, Callable[[Dict[str, Any]], None]]:
"""Return the subset of canonical hooks the plugin actually implements.
A hook is considered implemented when ``getattr(plugin, name)`` is a
callable. Missing or non-callable attributes are silently skipped
plugins are not required to implement every hook.
"""
found: Dict[str, Callable[[Dict[str, Any]], None]] = {}
for hook in _HOOK_NAMES:
candidate = getattr(plugin, hook, None)
if callable(candidate):
found[hook] = candidate
return found
def register_plugin(
*,
name: str,
version: str,
plugin: Any,
description: str = "",
templates: Optional[List[str]] = None,
model_groups: Optional[List[str]] = None,
) -> PluginSpec:
"""Register a plugin. Idempotent for an identical spec; rejects
re-registration with a different version or plugin object."""
_validate_name(name)
_validate_version(version)
_validate_description(description)
if plugin is None:
raise ValueError("plugin object must not be None")
# Hook discovery is best-effort: we don't require any hook, but at
# least one of {hooks, templates, model_groups} must be non-empty so a
# totally-empty plugin is rejected loudly.
hooks = discover_hooks(plugin)
tpls = tuple(templates or ())
grps = tuple(model_groups or ())
if len(tpls) > _MAX_TEMPLATES_PER_PLUGIN:
raise ValueError(
f"templates exceeds {_MAX_TEMPLATES_PER_PLUGIN} entries"
)
if len(grps) > _MAX_MODEL_GROUPS_PER_PLUGIN:
raise ValueError(
f"model_groups exceeds {_MAX_MODEL_GROUPS_PER_PLUGIN} entries"
)
for tpl in tpls:
if not isinstance(tpl, str) or not tpl or "\x00" in tpl:
raise ValueError("template name must be non-empty NUL-free str")
if len(tpl) > _MAX_NAME_ENTRY_LEN:
raise ValueError(
f"template name exceeds {_MAX_NAME_ENTRY_LEN} chars"
)
for grp in grps:
if not isinstance(grp, str) or not grp or "\x00" in grp:
raise ValueError("model_group name must be non-empty NUL-free str")
if len(grp) > _MAX_NAME_ENTRY_LEN:
raise ValueError(
f"model_group name exceeds {_MAX_NAME_ENTRY_LEN} chars"
)
if not hooks and not tpls and not grps:
raise ValueError(
"plugin must implement at least one hook OR register a template "
"OR register a model group"
)
spec = PluginSpec(
name=name,
version=version,
plugin=plugin,
description=description,
templates=tpls,
model_groups=grps,
)
with _LOCK:
if len(_PLUGINS) >= _MAX_PLUGINS and name not in _PLUGINS:
raise RuntimeError(f"too many plugins (max {_MAX_PLUGINS})")
existing = _PLUGINS.get(name)
if existing is not None:
if (
existing.version != version
or existing.plugin is not plugin
or existing.templates != tpls
or existing.model_groups != grps
or existing.description != description
):
raise ValueError(
f"plugin {name!r} already registered with a different spec"
)
# Identical re-register: keep enabled state.
return existing
_PLUGINS[name] = spec
return spec
def list_plugins() -> Mapping[str, PluginSpec]:
"""Return an immutable view of registered plugins."""
with _LOCK:
return MappingProxyType(dict(_PLUGINS))
def get_plugin(name: str) -> Optional[PluginSpec]:
"""Return the registered plugin spec for ``name``, or ``None``."""
if not isinstance(name, str):
return None
with _LOCK:
return _PLUGINS.get(name)
def enable_plugin(name: str) -> bool:
"""Mark a registered plugin enabled. Returns True iff it changed state."""
_validate_name(name)
with _LOCK:
existing = _PLUGINS.get(name)
if existing is None:
raise KeyError(name)
if existing.enabled:
return False
_PLUGINS[name] = PluginSpec(
name=existing.name,
version=existing.version,
plugin=existing.plugin,
description=existing.description,
enabled=True,
templates=existing.templates,
model_groups=existing.model_groups,
)
return True
def disable_plugin(name: str) -> bool:
"""Mark a registered plugin disabled. Returns True iff it changed state."""
_validate_name(name)
with _LOCK:
existing = _PLUGINS.get(name)
if existing is None:
raise KeyError(name)
if not existing.enabled:
return False
_PLUGINS[name] = PluginSpec(
name=existing.name,
version=existing.version,
plugin=existing.plugin,
description=existing.description,
enabled=False,
templates=existing.templates,
model_groups=existing.model_groups,
)
return True
def is_enabled(name: str) -> bool:
"""Return True iff ``name`` is registered and enabled."""
spec = get_plugin(name)
return bool(spec and spec.enabled)
def clear_plugins() -> None:
"""Remove all registered plugins. Used by tests."""
with _LOCK:
_PLUGINS.clear()
def load_plugins() -> int:
"""Import every ``soup_cli.plugins.*`` submodule. Returns count loaded.
Plugin failures are caught and logged at WARNING one bad plugin
must not crash ``soup`` startup (mirrors the v0.44.0 Web UI plugin
loader policy).
"""
count = 0
pkg = importlib.import_module(__name__)
for module_info in pkgutil.iter_modules(pkg.__path__):
if module_info.name.startswith("_"):
continue
try:
importlib.import_module(f"{__name__}.{module_info.name}")
count += 1
except Exception: # noqa: BLE001 — plugin failure must not crash CLI
logger.exception(
"Failed to load Soup plugin: %s", module_info.name
)
return count
__all__ = [
"BasePlugin",
"PluginSpec",
"discover_hooks",
"list_hook_names",
"register_plugin",
"list_plugins",
"get_plugin",
"enable_plugin",
"disable_plugin",
"is_enabled",
"clear_plugins",
"load_plugins",
]

View File

@ -0,0 +1,214 @@
"""v0.45.0 Part B — Anthropic Messages API converter (schema-only).
Pure-Python converter between OpenAI ``/v1/chat/completions`` payloads and
Anthropic ``/v1/messages`` payloads. The wire-up of the Anthropic-shaped
endpoint inside ``soup serve`` is deferred to v0.45.1 (matches the
project's stub-then-live policy).
Surface kept narrow on purpose:
- ``to_anthropic(openai_payload)`` -> dict in Anthropic shape
- ``from_anthropic(anthropic_payload)`` -> dict in OpenAI shape
- ``validate_anthropic_payload(p)`` -> raises on schema violations
"""
from __future__ import annotations
from typing import Any, Dict, List
# Caps mirror the v0.30.0 inference-server caps (max_tokens) and v0.40.3
# trace-log message-size policy.
_MAX_MESSAGES = 1024
_MAX_CONTENT_LEN = 1_048_576 # 1 MiB per message
_MAX_TOKENS_CAP = 16384 # matches /v1/chat/completions
_VALID_ROLES_OPENAI = frozenset({"system", "user", "assistant", "tool"})
_VALID_ROLES_ANTHROPIC = frozenset({"user", "assistant"})
def _check_str(value: Any, name: str, *, max_len: int = _MAX_CONTENT_LEN) -> str:
if not isinstance(value, str):
raise TypeError(f"{name} must be a string")
if "\x00" in value:
raise ValueError(f"{name} must not contain null bytes")
if len(value) > max_len:
raise ValueError(f"{name} exceeds {max_len} chars")
return value
def _check_messages(messages: Any) -> List[Dict[str, Any]]:
if not isinstance(messages, list):
raise TypeError("messages must be a list")
if not messages:
raise ValueError("messages must not be empty")
if len(messages) > _MAX_MESSAGES:
raise ValueError(f"messages exceeds {_MAX_MESSAGES} entries")
out: List[Dict[str, Any]] = []
for index, message in enumerate(messages):
if not isinstance(message, dict):
raise TypeError(f"messages[{index}] must be a dict")
out.append(message)
return out
def to_anthropic(openai_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an OpenAI chat-completions payload to Anthropic Messages shape.
- The (single) ``system`` message becomes the top-level ``system`` field.
- ``user`` / ``assistant`` messages are passed through.
- ``tool`` messages are surfaced as ``tool_result`` content blocks under
a ``user`` role (Anthropic convention).
- ``max_tokens`` is required by Anthropic and defaults to ``1024`` if
missing on the OpenAI side; capped at ``_MAX_TOKENS_CAP``.
"""
if not isinstance(openai_payload, dict):
raise TypeError("openai_payload must be a dict")
messages = _check_messages(openai_payload.get("messages"))
model = _check_str(openai_payload.get("model", ""), "model", max_len=256)
system_text: List[str] = []
out_messages: List[Dict[str, Any]] = []
for index, message in enumerate(messages):
role = message.get("role")
if not isinstance(role, str) or role not in _VALID_ROLES_OPENAI:
raise ValueError(
f"messages[{index}].role must be one of {_VALID_ROLES_OPENAI}"
)
content = message.get("content", "")
if isinstance(content, str):
_check_str(content, f"messages[{index}].content")
elif isinstance(content, list):
# Pass-through structured content (e.g. multi-modal). Caller is
# responsible for shape validity beyond NUL-byte checks.
for inner in content:
if isinstance(inner, dict) and isinstance(inner.get("text"), str):
_check_str(inner["text"], f"messages[{index}].content[].text")
else:
raise TypeError(
f"messages[{index}].content must be str or list"
)
if role == "system":
if isinstance(content, str):
system_text.append(content)
else:
raise TypeError("system message content must be a string")
continue
if role == "tool":
tool_call_id = _check_str(
message.get("tool_call_id", ""),
f"messages[{index}].tool_call_id",
max_len=256,
)
# Structured (list) content from OpenAI is concatenated into a
# single text block instead of being silently dropped — Anthropic
# ``tool_result`` accepts either str or a list of text/image
# blocks, but we keep the conversion lossless for the common
# text-only path. Non-text inner items are stringified via repr.
if isinstance(content, str):
tool_content: Any = content
elif isinstance(content, list):
parts: List[str] = []
for inner in content:
if isinstance(inner, dict) and isinstance(
inner.get("text"), str
):
parts.append(inner["text"])
elif isinstance(inner, str):
parts.append(inner)
tool_content = "\n".join(parts)
else:
raise TypeError(
f"messages[{index}].content must be str or list"
)
out_messages.append(
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": tool_content,
}
],
}
)
continue
out_messages.append({"role": role, "content": content})
raw_max = openai_payload.get("max_tokens", 1024)
if isinstance(raw_max, bool) or not isinstance(raw_max, int):
raise TypeError("max_tokens must be an int")
if raw_max < 1:
raise ValueError("max_tokens must be >= 1")
max_tokens = min(raw_max, _MAX_TOKENS_CAP)
out: Dict[str, Any] = {
"model": model,
"messages": out_messages,
"max_tokens": max_tokens,
}
if system_text:
out["system"] = "\n\n".join(system_text)
if "temperature" in openai_payload:
temperature = openai_payload["temperature"]
if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
raise TypeError("temperature must be int or float")
if not 0.0 <= float(temperature) <= 2.0:
raise ValueError("temperature must be in [0.0, 2.0]")
out["temperature"] = float(temperature)
return out
def from_anthropic(anthropic_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an Anthropic Messages payload back to OpenAI chat shape."""
validate_anthropic_payload(anthropic_payload)
out_messages: List[Dict[str, Any]] = []
system_field = anthropic_payload.get("system")
if isinstance(system_field, str) and system_field:
out_messages.append({"role": "system", "content": system_field})
for message in anthropic_payload["messages"]:
# validate_anthropic_payload above guarantees the role key is
# present and on the allowlist; ``.get`` is defence-in-depth so a
# post-validation iteration cannot raise ``KeyError``.
out_messages.append(
{
"role": message.get("role"),
"content": message.get("content", ""),
}
)
return {
"model": anthropic_payload["model"],
"messages": out_messages,
"max_tokens": anthropic_payload["max_tokens"],
}
def validate_anthropic_payload(payload: Dict[str, Any]) -> None:
"""Raise if the Anthropic payload is malformed."""
if not isinstance(payload, dict):
raise TypeError("payload must be a dict")
_check_str(payload.get("model", ""), "model", max_len=256)
messages = _check_messages(payload.get("messages"))
for index, message in enumerate(messages):
role = message.get("role")
if role not in _VALID_ROLES_ANTHROPIC:
raise ValueError(
f"messages[{index}].role must be one of {_VALID_ROLES_ANTHROPIC}"
)
raw_max = payload.get("max_tokens")
if isinstance(raw_max, bool) or not isinstance(raw_max, int):
raise TypeError("max_tokens must be an int")
if raw_max < 1 or raw_max > _MAX_TOKENS_CAP:
raise ValueError(f"max_tokens must be in [1, {_MAX_TOKENS_CAP}]")
if "system" in payload:
if not isinstance(payload["system"], str):
raise TypeError("system must be a string")
_check_str(payload["system"], "system")
__all__ = [
"to_anthropic",
"from_anthropic",
"validate_anthropic_payload",
]

View File

@ -0,0 +1,161 @@
"""v0.45.0 Part C — External integrations catalog (schema-only).
Closed allowlist of integration descriptors so a future ``soup deploy
<target>`` can know about LM Studio, ComfyUI, stable-diffusion.cpp,
Open WebUI, etc. without each command growing its own ad-hoc list. Live
launch wiring lands in v0.45.1.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping, Tuple
_INTEGRATION_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,31}$")
_MAX_DESCRIPTION = 256
@dataclass(frozen=True)
class IntegrationSpec:
"""One external integration entry."""
name: str
description: str
target_artifacts: Tuple[str, ...]
def _make(
name: str,
description: str,
target_artifacts: Tuple[str, ...],
) -> IntegrationSpec:
if not _INTEGRATION_NAME_RE.match(name):
raise ValueError(
"integration name must be kebab-case ([a-z0-9][a-z0-9-]{0,31})"
)
if not isinstance(description, str) or "\x00" in description:
raise ValueError("description must be a NUL-free string")
if len(description) > _MAX_DESCRIPTION:
raise ValueError(f"description exceeds {_MAX_DESCRIPTION} chars")
if not isinstance(target_artifacts, tuple) or not target_artifacts:
raise ValueError("target_artifacts must be a non-empty tuple")
for artifact in target_artifacts:
if not isinstance(artifact, str) or not artifact:
raise ValueError("target_artifacts entries must be non-empty str")
return IntegrationSpec(
name=name, description=description, target_artifacts=target_artifacts
)
_BUILTIN: Mapping[str, IntegrationSpec] = MappingProxyType(
{
"lm-studio": _make(
"lm-studio",
"Push merged GGUF to LM Studio via the lms CLI",
("gguf",),
),
"comfyui": _make(
"comfyui",
"Export node-graph compatible models for ComfyUI",
("safetensors", "gguf"),
),
"stable-diffusion-cpp": _make(
"stable-diffusion-cpp",
"Image-output model export for stable-diffusion.cpp",
("safetensors",),
),
"open-webui": _make(
"open-webui",
"Auto-register the served model with Open WebUI",
("served-endpoint",),
),
"ollama": _make(
"ollama",
"Deploy GGUF model to local Ollama instance",
("gguf",),
),
"tei": _make(
"tei",
"HuggingFace Text-Embeddings-Inference deployment",
("safetensors",),
),
"pgvector": _make(
"pgvector",
"Postgres pgvector schema + connection pattern",
("safetensors",),
),
"faiss": _make(
"faiss",
"FAISS index pattern for embedding-model output",
("safetensors",),
),
"weaviate": _make(
"weaviate",
"Weaviate vector-DB connection pattern",
("safetensors",),
),
"sentence-transformers": _make(
"sentence-transformers",
"Sentence-Transformers compatible embedding export",
("safetensors",),
),
"claude-code": _make(
"claude-code",
"Claude Code client SDK integration example",
("served-endpoint",),
),
"cursor": _make(
"cursor",
"Cursor IDE custom-model connection guide",
("served-endpoint",),
),
"continue": _make(
"continue",
"Continue VS Code extension custom-model guide",
("served-endpoint",),
),
"cline": _make(
"cline",
"Cline (autonomous IDE agent) connection guide",
("served-endpoint",),
),
"sillytavern": _make(
"sillytavern",
"SillyTavern chat front-end connection guide",
("served-endpoint",),
),
}
)
def list_integrations() -> Mapping[str, IntegrationSpec]:
"""Return an immutable view of the integration registry."""
return _BUILTIN
def get_integration(name: str) -> IntegrationSpec:
"""Return the integration entry for ``name`` or raise ``KeyError``."""
if not isinstance(name, str):
raise TypeError("name must be a string")
canonical = name.strip().lower()
if not canonical or "\x00" in canonical:
raise ValueError("name must be a non-empty NUL-free string")
if canonical not in _BUILTIN:
raise KeyError(canonical)
return _BUILTIN[canonical]
def has_integration(name: str) -> bool:
if not isinstance(name, str):
return False
return name.strip().lower() in _BUILTIN
__all__ = [
"IntegrationSpec",
"list_integrations",
"get_integration",
"has_integration",
]

View File

@ -0,0 +1,71 @@
"""v0.45.0 Part B — n-gram-mod speculative decoding (schema-only).
Validates a small configuration surface; the live engine wiring inside
the inference loop lands in v0.45.1.
"""
from __future__ import annotations
from dataclasses import dataclass
_MIN_N = 1
_MAX_N = 8
_MIN_DRAFT = 1
_MAX_DRAFT = 32
_MIN_PROMPT = 0
_MAX_PROMPT = 1_048_576
@dataclass(frozen=True)
class NgramSpecConfig:
"""N-gram speculative-decoding configuration."""
n: int
num_draft_tokens: int = 4
prompt_lookup_max: int = 0 # 0 disables prompt-lookup heuristic
def validate_ngram_n(n: int) -> int:
if isinstance(n, bool) or not isinstance(n, int):
raise TypeError("n must be an int")
if n < _MIN_N or n > _MAX_N:
raise ValueError(f"n must be in [{_MIN_N}, {_MAX_N}]")
return n
def validate_num_draft_tokens(value: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError("num_draft_tokens must be an int")
if value < _MIN_DRAFT or value > _MAX_DRAFT:
raise ValueError(
f"num_draft_tokens must be in [{_MIN_DRAFT}, {_MAX_DRAFT}]"
)
return value
def validate_prompt_lookup_max(value: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError("prompt_lookup_max must be an int")
if value < _MIN_PROMPT or value > _MAX_PROMPT:
raise ValueError(
f"prompt_lookup_max must be in [{_MIN_PROMPT}, {_MAX_PROMPT}]"
)
return value
def validate_ngram_config(config: NgramSpecConfig) -> NgramSpecConfig:
if not isinstance(config, NgramSpecConfig):
raise TypeError("config must be an NgramSpecConfig")
validate_ngram_n(config.n)
validate_num_draft_tokens(config.num_draft_tokens)
validate_prompt_lookup_max(config.prompt_lookup_max)
return config
__all__ = [
"NgramSpecConfig",
"validate_ngram_config",
"validate_ngram_n",
"validate_num_draft_tokens",
"validate_prompt_lookup_max",
]

View File

@ -0,0 +1,239 @@
"""v0.45.0 Part E — Data Recipe DAG validators (schema-only).
Parses a YAML recipe describing a Seed -> LLM Text -> Code -> Judge ->
Validators -> Sampler graph and validates the topology. The live runner
(execution against a local model) is deferred to v0.45.1.
"""
from __future__ import annotations
import os
import re
import stat
from collections import deque
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Dict, List, Mapping, Tuple
# Closed allowlist of node kinds. Mirrors the unsloth Studio schema.
_NODE_KINDS = (
"seed",
"llm_text",
"code",
"judge",
"validator",
"sampler",
)
NODE_KINDS: frozenset = frozenset(_NODE_KINDS)
# Per-recipe caps — defence-in-depth against pathological YAML.
_MAX_NODES = 256
_MAX_EDGES = 1024
_MAX_NAME_LEN = 64
_MAX_FILE_BYTES = 1_048_576 # 1 MiB
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]{0,63}$")
@dataclass(frozen=True)
class RecipeNode:
"""One node in the data-recipe DAG."""
name: str
kind: str
config: Mapping[str, Any]
@dataclass(frozen=True)
class RecipeDAG:
"""Validated recipe topology."""
nodes: Tuple[RecipeNode, ...]
edges: Tuple[Tuple[str, str], ...]
topo_order: Tuple[str, ...]
def _check_name(name: str) -> str:
if not isinstance(name, str):
raise TypeError("node name must be a string")
if "\x00" in name:
raise ValueError("node name must not contain null bytes")
if not _NAME_RE.match(name):
raise ValueError(
f"node name must match [a-z0-9][a-z0-9_-]{{0,{_MAX_NAME_LEN - 1}}}"
)
return name
def _check_kind(kind: str) -> str:
if not isinstance(kind, str):
raise TypeError("node kind must be a string")
canonical = kind.strip().lower()
if canonical not in NODE_KINDS:
raise ValueError(
f"unknown node kind: {kind!r}. supported: {sorted(NODE_KINDS)}"
)
return canonical
def _topological_sort(
names: List[str], edges: List[Tuple[str, str]]
) -> List[str]:
"""Kahn's algorithm. Raises ``ValueError`` on cycle.
Uses ``collections.deque`` so each pop is O(1); successors that become
zero in-degree on the same step are appended in sorted order so the
output is deterministic without re-sorting the queue every iteration.
"""
in_degree: Dict[str, int] = {name: 0 for name in names}
successors: Dict[str, List[str]] = {name: [] for name in names}
for source, target in edges:
in_degree[target] += 1
successors[source].append(target)
queue: deque = deque(
sorted(name for name, deg in in_degree.items() if deg == 0)
)
order: List[str] = []
while queue:
current = queue.popleft()
order.append(current)
ready: List[str] = []
for successor in successors[current]:
in_degree[successor] -= 1
if in_degree[successor] == 0:
ready.append(successor)
ready.sort()
queue.extend(ready)
if len(order) != len(names):
raise ValueError("recipe DAG contains a cycle")
return order
def parse_recipe(raw: Any) -> RecipeDAG:
"""Validate and topologically sort a recipe dict.
Required shape::
{
"nodes": [{"name": "...", "kind": "...", "config": {...}}, ...],
"edges": [["from_name", "to_name"], ...]
}
"""
if not isinstance(raw, dict):
raise TypeError("recipe must be a dict")
raw_nodes = raw.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise ValueError("recipe.nodes must be a non-empty list")
if len(raw_nodes) > _MAX_NODES:
raise ValueError(f"recipe.nodes exceeds {_MAX_NODES} entries")
nodes: List[RecipeNode] = []
seen_names: set[str] = set()
for index, raw_node in enumerate(raw_nodes):
if not isinstance(raw_node, dict):
raise TypeError(f"recipe.nodes[{index}] must be a dict")
name = _check_name(raw_node.get("name", ""))
if name in seen_names:
raise ValueError(f"duplicate node name: {name!r}")
seen_names.add(name)
kind = _check_kind(raw_node.get("kind", ""))
config = raw_node.get("config", {})
if not isinstance(config, dict):
raise TypeError(f"recipe.nodes[{index}].config must be a dict")
nodes.append(
RecipeNode(name=name, kind=kind, config=MappingProxyType(dict(config)))
)
raw_edges = raw.get("edges", [])
if not isinstance(raw_edges, list):
raise TypeError("recipe.edges must be a list")
if len(raw_edges) > _MAX_EDGES:
raise ValueError(f"recipe.edges exceeds {_MAX_EDGES} entries")
edges: List[Tuple[str, str]] = []
edge_seen: set[Tuple[str, str]] = set()
name_set = {node.name for node in nodes}
for index, raw_edge in enumerate(raw_edges):
if not isinstance(raw_edge, (list, tuple)) or len(raw_edge) != 2:
raise ValueError(
f"recipe.edges[{index}] must be a 2-element [from, to] list"
)
source = _check_name(raw_edge[0])
target = _check_name(raw_edge[1])
if source == target:
raise ValueError(f"self-loop edge rejected: {source!r}")
if source not in name_set:
raise ValueError(f"edge source {source!r} not in nodes")
if target not in name_set:
raise ValueError(f"edge target {target!r} not in nodes")
key = (source, target)
if key in edge_seen:
raise ValueError(f"duplicate edge: {source!r} -> {target!r}")
edge_seen.add(key)
edges.append(key)
topo = _topological_sort([n.name for n in nodes], edges)
return RecipeDAG(
nodes=tuple(nodes),
edges=tuple(edges),
topo_order=tuple(topo),
)
def parse_recipe_yaml(text: str) -> RecipeDAG:
"""Parse a YAML string into a validated ``RecipeDAG``."""
if not isinstance(text, str):
raise TypeError("text must be a string")
if "\x00" in text:
raise ValueError("recipe text must not contain null bytes")
if len(text.encode("utf-8")) > _MAX_FILE_BYTES:
raise ValueError(f"recipe text exceeds {_MAX_FILE_BYTES} bytes")
import yaml # lazy import — keep CLI startup fast
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ValueError(f"invalid YAML: {exc}") from exc
return parse_recipe(data)
def load_recipe_yaml(path: str) -> RecipeDAG:
"""Load a recipe YAML from a path under cwd."""
from soup_cli.utils.paths import is_under_cwd
if not isinstance(path, str) or not path:
raise ValueError("path must be a non-empty string")
if "\x00" in path:
raise ValueError("path must not contain null bytes")
# Symlink rejection on the *original* path (TOCTOU policy mirroring
# v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B). ``realpath`` below
# would already follow symlinks, but we want to reject them as a
# defence layer rather than silently follow.
try:
lstat_result = os.lstat(path)
except FileNotFoundError as exc:
raise FileNotFoundError(path) from exc
if stat.S_ISLNK(lstat_result.st_mode):
raise ValueError(
f"recipe path must not be a symlink: {os.path.basename(path)}"
)
real = os.path.realpath(path)
if not is_under_cwd(real):
raise ValueError(f"recipe path must stay under cwd: {os.path.basename(real)}")
if not os.path.isfile(real):
raise FileNotFoundError(real)
size = os.path.getsize(real)
if size > _MAX_FILE_BYTES:
raise ValueError(f"recipe file exceeds {_MAX_FILE_BYTES} bytes")
with open(real, "r", encoding="utf-8") as handle:
text = handle.read()
return parse_recipe_yaml(text)
__all__ = [
"NODE_KINDS",
"RecipeDAG",
"RecipeNode",
"parse_recipe",
"parse_recipe_yaml",
"load_recipe_yaml",
]

View File

@ -0,0 +1,179 @@
"""v0.45.0 Part B — Server-side tools registry (schema-only).
Exposes a closed allowlist of safe tools that the inference server can offer
to a model: ``python``, ``bash``, ``web_search``. Live wiring (HTTP tool
endpoints) is deferred to v0.45.1; this module locks the public schema +
domain allowlist so the configuration surface is stable now.
Defence-in-depth choices:
- ``python`` and ``bash`` reuse the v0.25.0 RLVR sandbox (5 s timeout,
RLIMIT_AS, ephemeral cwd, socket patch) schema only here.
- ``web_search`` requires every domain on a closed allowlist; subdomain
matches require a leading dot (``foo.example.com`` matches
``example.com`` only when the allow entry is ``.example.com``).
- ``rate_limit`` per tool uses the v0.20.0 [1, 600] requests-per-minute
window so a misbehaving plugin cannot DoS the server.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping, Optional, Tuple
# Tool name allowlist — closed set, used as map keys.
_TOOL_NAMES = ("python", "bash", "web_search")
SUPPORTED_TOOLS: frozenset = frozenset(_TOOL_NAMES)
# Default web_search domain allowlist. Operators must override / extend
# explicitly via ``WebSearchConfig.domain_allowlist``; the empty default
# is intentionally a hard "deny all".
_DEFAULT_WEB_DOMAINS: Tuple[str, ...] = ()
_DOMAIN_RE = re.compile(r"^\.?[a-z0-9]([a-z0-9.\-]{0,253})$")
_MAX_DOMAINS = 64
_MAX_DOMAIN_LEN = 253
_MIN_RPM = 1
_MAX_RPM = 600
@dataclass(frozen=True)
class WebSearchConfig:
"""Web-search tool configuration."""
domain_allowlist: Tuple[str, ...] = _DEFAULT_WEB_DOMAINS
rate_limit_per_minute: int = 30
@dataclass(frozen=True)
class ToolSpec:
"""One server-side tool registration."""
name: str
description: str
rate_limit_per_minute: int
web_search: Optional[WebSearchConfig] = None
def validate_tool_name(name: str) -> str:
"""Validate ``name`` is one of the allowlisted tools."""
if not isinstance(name, str):
raise TypeError("tool name must be a string")
canonical = name.strip().lower()
if not canonical:
raise ValueError("tool name must be non-empty")
if "\x00" in canonical:
raise ValueError("tool name must not contain null bytes")
if canonical not in SUPPORTED_TOOLS:
raise ValueError(
f"unknown tool: {canonical!r}. supported: {sorted(SUPPORTED_TOOLS)}"
)
return canonical
def validate_rate_limit(rpm: int) -> int:
if isinstance(rpm, bool) or not isinstance(rpm, int):
raise TypeError("rate_limit_per_minute must be an int")
if rpm < _MIN_RPM or rpm > _MAX_RPM:
raise ValueError(
f"rate_limit_per_minute must be in [{_MIN_RPM}, {_MAX_RPM}]"
)
return rpm
def validate_domain(domain: str) -> str:
if not isinstance(domain, str):
raise TypeError("domain must be a string")
candidate = domain.strip().lower()
if not candidate:
raise ValueError("domain must be non-empty")
if "\x00" in candidate:
raise ValueError("domain must not contain null bytes")
if len(candidate) > _MAX_DOMAIN_LEN:
raise ValueError(f"domain exceeds {_MAX_DOMAIN_LEN} chars")
if "/" in candidate or " " in candidate:
raise ValueError("domain must not contain '/' or whitespace")
if not _DOMAIN_RE.match(candidate):
raise ValueError(f"invalid domain syntax: {candidate!r}")
return candidate
def validate_web_search_config(config: WebSearchConfig) -> WebSearchConfig:
if not isinstance(config, WebSearchConfig):
raise TypeError("config must be a WebSearchConfig")
if len(config.domain_allowlist) > _MAX_DOMAINS:
raise ValueError(f"domain_allowlist exceeds {_MAX_DOMAINS} entries")
seen = set()
for raw in config.domain_allowlist:
canonical = validate_domain(raw)
if canonical in seen:
raise ValueError(f"duplicate domain: {canonical!r}")
seen.add(canonical)
validate_rate_limit(config.rate_limit_per_minute)
return config
def is_domain_allowed(host: str, allowlist: Tuple[str, ...]) -> bool:
"""Return True iff ``host`` is permitted by ``allowlist``.
Bare entries (``example.com``) match the host exactly. Entries with a
leading dot (``.example.com``) also match any subdomain
(``a.example.com``). Both forms are validated by ``validate_domain``.
"""
if not isinstance(host, str) or not host:
return False
canonical = host.strip().lower()
if not canonical or "\x00" in canonical:
return False
# Strip optional ``:port`` suffix and bracketed IPv6 noise so an
# ``Authority``-form value like ``api.example.com:443`` matches the
# bare allowlist entry ``api.example.com`` instead of silently
# missing.
if canonical.startswith("[") and "]" in canonical:
# IPv6 literal — never matches a domain allowlist; deny.
return False
if ":" in canonical:
canonical = canonical.split(":", 1)[0]
if not canonical:
return False
for raw in allowlist:
rule = raw.strip().lower()
if not rule:
continue
if rule.startswith("."):
base = rule[1:]
if canonical == base or canonical.endswith(rule):
return True
elif canonical == rule:
return True
return False
_TOOL_DESCRIPTIONS: Mapping[str, str] = MappingProxyType(
{
"python": "Sandboxed Python execution (RLVR sandbox)",
"bash": "Sandboxed bash execution (RLVR sandbox)",
"web_search": "Web search constrained to a domain allowlist",
}
)
def tool_description(name: str) -> str:
"""Return the canonical description for a supported tool."""
canonical = validate_tool_name(name)
return _TOOL_DESCRIPTIONS[canonical]
__all__ = [
"SUPPORTED_TOOLS",
"ToolSpec",
"WebSearchConfig",
"is_domain_allowed",
"tool_description",
"validate_domain",
"validate_rate_limit",
"validate_tool_name",
"validate_web_search_config",
]

View File

@ -0,0 +1,136 @@
"""v0.45.0 Part D — Advanced trainer-plugin allowlist (schema-only).
Closed allowlist of optional trainer plugins so a future
``training.trainer_plugins: [grokfast, spectrum, ...]`` Pydantic field
can validate against a stable surface. Live wiring (callbacks, kernel
swaps, LLMCompressor passes) is deferred to v0.45.1.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping, Optional, Sequence, Tuple
_PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_]{0,31}$")
_MAX_DESCRIPTION = 256
_MAX_PLUGINS_PER_RUN = 8
@dataclass(frozen=True)
class TrainerPluginSpec:
"""One advanced trainer plugin descriptor."""
name: str
description: str
required_package: Optional[str]
def _make(
name: str,
description: str,
required_package: Optional[str],
) -> TrainerPluginSpec:
if not _PLUGIN_NAME_RE.match(name):
raise ValueError(
"trainer-plugin name must be snake_case ([a-z0-9][a-z0-9_]{0,31})"
)
if not isinstance(description, str) or "\x00" in description:
raise ValueError("description must be a NUL-free string")
if len(description) > _MAX_DESCRIPTION:
raise ValueError(f"description exceeds {_MAX_DESCRIPTION} chars")
if required_package is not None:
if not isinstance(required_package, str) or not required_package:
raise ValueError("required_package must be a non-empty string")
return TrainerPluginSpec(
name=name, description=description, required_package=required_package
)
_BUILTIN: Mapping[str, TrainerPluginSpec] = MappingProxyType(
{
"cce_plugin": _make(
"cce_plugin",
"Cut Cross-Entropy plugin variant (axolotl binding)",
"cut-cross-entropy",
),
"grokfast": _make(
"grokfast",
"Gradient-grokking accelerator (axolotl)",
"grokfast",
),
"spectrum": _make(
"spectrum",
"Gradient-norm-based layer freezing (axolotl)",
None,
),
"llmcompressor": _make(
"llmcompressor",
"Post-training compression (axolotl)",
"llmcompressor",
),
"sonicmoe": _make(
"sonicmoe",
"Alternative MoE kernel (axolotl)",
None,
),
"math_verify": _make(
"math_verify",
"Standalone math reward verifier (promoted v0.25.0)",
None,
),
}
)
def list_trainer_plugins() -> Mapping[str, TrainerPluginSpec]:
"""Return an immutable view of the registry."""
return _BUILTIN
def get_trainer_plugin(name: str) -> TrainerPluginSpec:
if not isinstance(name, str):
raise TypeError("name must be a string")
canonical = name.strip().lower()
if not canonical or "\x00" in canonical:
raise ValueError("name must be a non-empty NUL-free string")
if canonical not in _BUILTIN:
raise KeyError(canonical)
return _BUILTIN[canonical]
def validate_trainer_plugin_list(names: Sequence[str]) -> Tuple[str, ...]:
"""Validate a list of trainer-plugin names. Returns canonical names."""
if isinstance(names, str) or not isinstance(names, (list, tuple)):
raise TypeError("names must be a list or tuple")
if len(names) > _MAX_PLUGINS_PER_RUN:
raise ValueError(
f"too many trainer plugins (max {_MAX_PLUGINS_PER_RUN})"
)
out: list[str] = []
seen: set[str] = set()
for raw in names:
if not isinstance(raw, str):
raise TypeError("plugin name must be a string")
canonical = raw.strip().lower()
if not canonical or "\x00" in canonical:
raise ValueError("plugin name must be non-empty NUL-free")
if canonical not in _BUILTIN:
raise ValueError(
f"unknown trainer plugin: {canonical!r}. supported: "
f"{sorted(_BUILTIN)}"
)
if canonical in seen:
raise ValueError(f"duplicate trainer plugin: {canonical!r}")
seen.add(canonical)
out.append(canonical)
return tuple(out)
__all__ = [
"TrainerPluginSpec",
"list_trainer_plugins",
"get_trainer_plugin",
"validate_trainer_plugin_list",
]

1202
tests/test_v0450.py Normal file

File diff suppressed because it is too large Load Diff