mirror of https://github.com/razor-ai/soup.git
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:
parent
b4924b12a1
commit
74301843a2
|
|
@ -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
132
README.md
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Soup CLI — Fine-tune LLMs in one command."""
|
||||
|
||||
__version__ = "0.44.0"
|
||||
__version__ = "0.45.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.[/]"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue