feat(correctness): v0.36.0 — Correctness First (4 Parts: A/B/C/D)

Four silent-failure modes Soup had → loud failures, plus a
security default-deny.

- Part A: assistant-only loss masking (default true). Replaces TRL's
  multi-turn heuristic with explicit IGNORE_INDEX masking. New
  data.train_on_responses_only / train_on_messages_with_train_field
  + per-message train: bool field. Preferred path uses
  return_assistant_tokens_mask; fallback uses incremental tokenize
  delta with add_special_tokens=False to avoid double-BOS drift.
- Part B: --trust-remote-code opt-in default-deny on soup train /
  chat / serve / data download / eval auto. KNOWN_SAFE_PREFIXES
  allowlist (15 first-party orgs) suppresses warning panel.
  Replaces 9 unconditional trust_remote_code=True call sites in
  the SFT path. Non-SFT trainers + diff/export/merge/infer/generate
  still hardcode trust_remote_code=True — documented v0.36.x patch.
- Part C: chat-template hardening. Tokenizers without chat_template
  raise loudly instead of silent f"{role}: {content}" fallback.
  New data.chat_template (registered name or raw Jinja). Filesystem
  -touching Jinja directives (include/import/from/macro/extends)
  blocked at config-load. Override application warns that soup push
  will persist the new Jinja into tokenizer_config.json.
- Part D: OOM-probe auto batch-size. New
  training.auto_batch_size_strategy: auto|static|probe. Try-halve
  -then-double-to-ceiling loop, max 8 doublings, ceiling = static
  × 4. ~/.soup/batch_cache.json (0600 perms, env-override
  containment-checked against ~/cwd/tempdir). make_cache_key
  rejects bool inputs.

Net +134 tests (4115 → 4249). All 5 review-agent waves clean
before commit; 5 HIGH / 10 MEDIUM / 5 LOW findings fixed in one
review-fix wave.

Smoke: python -m soup_cli.cli version → soup v0.36.0; all 5 new
--trust-remote-code flags surface in --help; ruff clean; pytest
4249 passed / 3 skipped / 0 failed in 2m41s on Windows py3.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-04-30 11:52:51 +05:00
parent f6e2004c9f
commit a5540fa1e2
21 changed files with 2688 additions and 42 deletions

View File

@ -110,7 +110,7 @@ soup_cli/
utils/ - GPU, errors, MoE, GaLore, QAT, Unsloth, vLLM, SGLang, Liger, FlashAttn, FSDP, Ring Attention, long-context, quality, curriculum, freeze, dataset-registry, mlx, peft_builder, paths, topology, launcher, mii, pipeline, cut_ce, fp8, gradient_ckpt, kernel_picker, cross_doc_attn, activation_offload, hf, spec_pairing, structured_output, metrics, tracing, auto_quant, lr_finder, grad_accum, mixed_precision, warmup, spike_recovery, convergence, v028_features
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (112 files, 4115 tests)
tests/ - Test suite (116 files, 4249 tests)
examples/ - Real-world config examples and datasets
```

View File

@ -40,14 +40,13 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.35.0 — Trainer Coverage**: every training task now uses every speed/memory feature — finishing what v0.28, v0.30, and v0.33 started.
**v0.36.0 — Correctness First**: four silent-failure modes Soup had → loud failures. Plus a security-hardening default everyone has been asking for.
- **v0.28 features wired into 8 more trainers**`use_cut_ce`, `quantization_aware="fp8"`, `kernel_auto_compose`, and `activation_offloading` are now live across DPO, GRPO, KTO, ORPO, SimPO, IPO, PPO, Reward-Model, and Embedding (in addition to SFT and pretraining). The schema-gate that previously rejected these flags on non-SFT trainers is lifted; only the Apple Silicon MLX backend still rejects them (no equivalent kernels).
- **Auto-quant actually swaps the model**`soup serve --auto-quant` now forwards the picked candidate's quantization (`awq` / `gptq` / `fp8`) to the vLLM engine via an explicit `quantization` parameter, rather than printing the choice and serving the original fp16. Includes a fallback queue (`try_reload_with_fallback`) so a missing AWQ kernel automatically falls back to the next-highest-scored candidate instead of crashing the bind.
- **Kernel benchmark warm-up runs inside the trainer** — when `kernel_auto_compose=True`, Soup now runs a forward-only timing loop on the trainer's actual model to feed real measurements into `pick_best_kernel`. Forward-only under `torch.no_grad()` so the live training model's gradients are NOT polluted (this was a critical-class bug fixed pre-tag).
- **fp8 / int8 QAT guard** — six trainer wrappers had `if tcfg.quantization_aware:` without excluding `"fp8"`, which would silently route the FP8 string into the legacy int8 QAT path. Fixed.
- **40-case smoke matrix** — every trainer × every v0.28 feature is exercised on every CI matrix job (no more "wired in theory, broken in practice" risk).
- **Distinct error messages** — schema-gate rejection now names the actual reason (MLX backend vs unknown task) so you don't waste time blaming MLX for a non-MLX failure.
- **Assistant-only loss masking** — Soup now masks non-assistant tokens with `IGNORE_INDEX` (-100) by default, so multi-turn chat data trains only on the assistant turn. Mirrors LlamaFactory + Axolotl. Replaces TRL's heuristic that produced wrong loss labels on intermediate user/system turns. Toggle via `data.train_on_responses_only: false`. Per-message `train: bool` field also supported (`train_on_messages_with_train_field: true`).
- **`--trust-remote-code` opt-in (default deny)** — `soup train`, `chat`, `serve`, `data download`, `eval auto` now refuse to load HF models that ship custom Python (`auto_map`) unless you pass `--trust-remote-code`. Allowlist of 15 first-party orgs (Meta, Mistral, Qwen, Google, etc.) suppresses warning noise. Replaces 9 unconditional `trust_remote_code=True` call sites.
- **Hard error on missing chat-template** — Soup no longer silently falls back to `f"{role}: {content}"` (which produced garbage labels). Tokenizers without `chat_template` now raise loudly with a fix suggestion. New `data.chat_template` field accepts a registered name (`chatml` / `llama3` / `qwen2.5` / `mistral` / `gemma3` / `phi4` / `deepseek-r1`) or a raw Jinja string. Filesystem-touching Jinja directives (`include`, `import`, `from`, `macro`, `extends`) are blocked at config-load.
- **OOM-probe auto batch-size + cache**`auto_batch_size_strategy: probe` (default `auto`) replaces the static formula with a real try-halve-then-double loop bounded by `max 8 doublings, ceiling = static × 4`. Picked size is cached at `~/.soup/batch_cache.json` keyed on `(model, max_length, quant, lora_r, gpu, gpu_gb)` so repeat runs short-circuit. Cache file written with 0600 perms; env-var override path is containment-checked.
- **Net +134 tests** (4115 → 4249) covering all four correctness fixes, the Jinja directive blocklist, cache containment, and the trust-remote-code resolution gate.
## Why Soup?
@ -514,6 +513,54 @@ training:
Not compatible with unsloth (own memory manager) or mlx. Wired across every transformer-backend trainer (SFT, DPO, GRPO, KTO, ORPO, SimPO, IPO, PPO, Reward-Model, Embedding, Pretrain).
## Correctness First (v0.36.0)
Four silent-failure modes Soup had → loud failures.
### Assistant-only loss masking
By default, Soup masks every non-assistant token with `-100` so the SFT loss reflects only what the model should *generate*. Toggle via `data.train_on_responses_only` (default `true`):
```yaml
data:
train: data.jsonl
train_on_responses_only: true # default
# OR per-message control:
# train_on_messages_with_train_field: true
```
When the tokenizer ships a chat template with `{% generation %}` markers, the mask is exact. Without those markers, Soup falls back to an incremental tokenize-delta walk and documents the looseness.
### `--trust-remote-code` opt-in
`soup train`, `chat`, `serve`, `data download`, `eval auto` now require `--trust-remote-code` to load any HF model that ships custom Python (`auto_map` in `config.json`). First-party orgs (Meta, Mistral, Qwen, Google, etc.) suppress the warning panel; everything else prints a `REMOTE CODE WARNING` panel before loading.
```bash
soup train --config soup.yaml --trust-remote-code
```
### Chat-template hardening
Tokenizers without a chat template now raise a `ValueError` with a fix suggestion instead of silently building garbage `f"{role}: {content}"` strings.
```yaml
data:
train: data.jsonl
chat_template: chatml # or: llama3, qwen2.5, mistral, gemma3, phi4, deepseek-r1, or a raw Jinja string
```
Raw Jinja strings are validated: null bytes / >64KB / filesystem-touching directives (`{% include %}`, `{% import %}`, `{% from %}`, `{% macro %}`, `{% extends %}`) are rejected at config-load.
### OOM-probe auto batch size
```yaml
training:
batch_size: auto # unchanged
auto_batch_size_strategy: probe # NEW: 'static' | 'probe' | 'auto' (default)
```
Replaces the static memory formula with a real try-halve-then-double-to-ceiling loop. Picked size is cached at `~/.soup/batch_cache.json` keyed on `(model, max_length, quantization, lora_r, gpu_name, gpu_memory_gb)` so repeat runs short-circuit.
## DPO Training
Train with preference data using Direct Preference Optimization:

View File

@ -9,9 +9,9 @@ We provide security updates for the following versions:
- **Versions older than 3 minor versions:** No support
Example:
- v0.35.0-0.35.x -- Full support (latest)
- v0.34.0-0.34.x -- Bug-fix support only
- v0.33.x and below -- No support
- v0.36.0-0.36.x -- Full support (latest)
- v0.35.0-0.35.x -- Bug-fix support only
- v0.34.x and below -- No support
## Reporting a Vulnerability
@ -142,6 +142,7 @@ No known critical vulnerabilities in current releases.
- **v0.32.0 — Training Stability & Auto-Tuning**: `--find-lr-output` containment via shared `utils/paths.is_under_cwd` (prevents writes outside cwd); `save_lr_finder_report` rejects NaN / Infinity floats in `lrs` / `losses` and serialises with `allow_nan=False` (keeps the report parser-safe); `compute_lr_schedule` rejects non-positive `start_lr`, inverted ranges, and `num_steps` outside `[2, 10_000]`; `pick_mixed_precision` rejects empty / null-byte / >200-char model names and resolves multi-version quirks (`qwen2.5` vs `qwen2`, `phi-3.5` vs `phi-3`) by longest-substring-first iteration so an added family can never accidentally make a more-specific entry dead code; `compute_warmup_steps` clamps to `[10, 1000]` with a `ratio==0.0` short-circuit matching HF Trainer's "no warmup" convention; `SpikeRecoveryStrategy` is `@dataclass(frozen=True)` (post-construction mutation cannot bypass validation), `max_attempts ∈ [1, 10]`, `lr_decay ∈ (0, 1)`, `min_lr > 0`; cross-validator `_validate_spike_recovery_requires_watchdog` rejects `loss_spike_recovery=true, loss_watchdog=false` at config-load (fails fast instead of never triggering); `convergence_window ∈ [5, 10_000]`, `convergence_rel_tol ∈ (0, 1]`, `recommend_action` reuses `detect_plateau` so plateau heuristic stays single-source-of-truth; `GradAccumMonitor.recommend()` caps doubled `accum` at `MAX_ACCUM=1024` so a runaway advisory loop cannot blow up DataLoader prefetch; `generate_config` validates BOTH the YAML output path AND the embedded `decisions["output"]` field via `is_under_cwd` (closes the gap where a crafted `decisions["output"]="../../etc"` would have silently propagated into the rendered YAML)
- **v0.34.0 — Observability & Dev UX**: `.crash` bundle generator (`utils/crash.py`) recursively redacts `hf_*` / `sk-*` / `Bearer …` token-shaped strings in any captured `config` and metric tail before serialisation, so a `.crash` file shared on a public GitHub issue cannot leak credentials; `output_dir` is reduced to `os.path.basename` so `$HOME` doesn't leak; `write_crash_bundle` uses `os.path.realpath + commonpath` for cwd containment (Windows-safe; raises `ValueError` not `PermissionError` so callers cannot silently swallow with `except OSError`); filename appends `secrets.token_hex(4)` so two crashes in the same UTC second don't collide; bundle truncated to `MAX_BUNDLE_BYTES=1_000_000`. `train.py` crash-write surfaces failures to the user (no silent missing-bundle). `profiling.py` `resolve_trace_path` rejects empty / `.` / `..` / `/` / `\\` / null-byte `run_id` (closes the `output_dir/profiles/../trace.json` escape) and uses `os.path.realpath + is_under_cwd`; profiles dir is created only on successful torch import (no stale empty dirs on torch-less CI). `tracker.get_run` LIKE-prefix match escapes `%` / `_` / `\\` and uses `ESCAPE '\\'` so a crafted `run_id` cannot widen the match (mirrors v0.26.0 registry policy). Lazy schema migration (`_ensure_schema`) tolerates the "duplicate column" race when two CLI processes start simultaneously on a fresh DB (fork-based multi-GPU training, TUI auto-refresh). `runs.py show/replay/clean` switched user `run_id` rendering to `markup_escape` and switched `clean` containment from broken `Path.resolve() + relative_to()` to project-standard `os.path.realpath + is_under_cwd`. `tui_app.py` lazy-imports `ExperimentTracker` and `markup_escape`s every DB-sourced string before passing into Textual widgets so a crafted base_model / experiment_name cannot inject `[bold red]…[/]` markup. `run_cost.estimate_run_cost_usd` rejects `bool` in `num_gpus` (bool is a subclass of int — same defence as v0.30.0 `Candidate.__post_init__`); duration clamped to `[0, 1 year]`; unknown GPU returns `None` so callers render `—` instead of fabricating `$0.00`. `log_level.parse_log_level` rejects non-string + null-byte input.
- **v0.33.0 — Live Wire**: RLVR `code_exec_reward` adds OS-level isolation (Linux best-effort `os.unshare(CLONE_NEWUSER|CLONE_NEWNET|CLONE_NEWPID)`, macOS `sandbox-exec` with default-deny `MACOS_SANDBOX_PROFILE` narrowed to a 3-name `mach-lookup` allowlist to prevent DNS / NSURLSession bypass of `(deny network*)`); `prune_checkpoints` switches to TOCTOU-safe `os.lstat + S_ISLNK` + `shutil.rmtree(onerror=_abort_on_symlink)` so a symlink encountered mid-walk aborts rather than escapes; `run_gate` wraps each task scorer in a typed `try/except` so backend failures produce `score=None, error=str(exc)` (never silent `score=1.0`); `_parse_judge_url` removes the bare `http://` catch-all (defence-in-depth after the Pydantic GateTask validator); `soup can run` requires `--yes` or explicit consent callback and raises `ValueError` (not `PermissionError`, which is an `OSError` subclass that broad `except` blocks would swallow); GGUF `rglob` result for ollama deploy is `realpath+commonpath` checked against extract_dir (prevents symlink escape from a crafted can); `DeployTarget.path` validator normalises mixed `\\`/`/` separators before splitting (closes a Windows `..` bypass); `CAN_FORMAT_VERSION` 1→2 (additive — v1 still loads); `soup can publish` validates `repo_id` via `utils/hf.validate_repo_id`, resolves token via `resolve_token`, sanitises commit messages (first-line, 200-char cap), uses HTTPS-only HfApi; `_write_spike_recovery_hint` adds `is_under_cwd` containment check on `args.output_dir` from raw HF `TrainingArguments`; `lookup_entry_by_output_dir` emits `ResourceWarning` when 1000-row scan limit is hit (no silent miss); `CrossDocCollator` no longer mutates input feature dicts (HF Dataset rows are cached and reused — mutation broke subsequent batches); `Candidate` rejects `bool` in `score`/`latency_ms` (was sneaking past `int` isinstance check); `evaluate_candidate` latency mean now divides by *completed* prompts (excludes crashed) so a broken candidate isn't artificially fast; `auto_quant.run_auto_quant_picker` soft-falls-back to highest-scored candidate when no candidate clears `min_score` (server still binds); `build_logits_processors` returns `[]` when neither `outlines` nor `lm-format-enforcer` is installed (server degrades to free-form rather than 500); MII server uses loopback-only CORS, max_tokens cap [1, 16384], stream rejection, generic 500 with no stack-trace leak; `os.execvp` auto-reexec uses list args (no shell), all forwarded flags pre-validated; `cleanup_extract_dir` uses `os.path.commonpath` (Windows-safe) instead of `startswith`; `_run_subprocess` catches `TimeoutExpired` and returns rc=124 (coreutils convention) instead of an unhandled traceback; new `eval_results` and `tensorrt` artifact kinds in `RegistryStore._VALID_KINDS`
- **v0.36.0 — Correctness First**: `--trust-remote-code` opt-in replaces 9 unconditional `trust_remote_code=True` call sites across `soup train` / `chat` / `serve` / `data download` / `eval auto`; `KNOWN_SAFE_PREFIXES` allowlist (15 first-party orgs) suppresses warning panel for trusted repos; `model_requires_trust_remote_code` probes local `config.json` for `auto_map` (HF Hub repo IDs return `None`/unknown — HF still raises loudly when custom code is actually needed); `resolve_trust_remote_code` raises `ValueError` with actionable message when model needs custom code but the user did not opt in. Chat-template hardening: `DataConfig.chat_template` validator rejects null bytes, oversize (>64KB), AND filesystem-touching Jinja directives (`{% include %}`, `{% import %}`, `{% from %}`, `{% macro %}`, `{% extends %}` — both whitespace-control variants); empty string normalised to `None`; `_REGISTRY` wrapped in `MappingProxyType` (callers cannot mutate); `apply_chat_template_override` emits yellow advisory when active so users know `soup push` will persist the override into `tokenizer_config.json`. SFT silent f-string fallback (`f"{role}: {content}"`) replaced with hard `ValueError` — produced wrong loss labels for years on tokenizers without a chat template. Loss-mask fallback passes `add_special_tokens=False` to incremental tokenize calls so HF cannot double-prepend BOS at front of each render (consistent prefix-delta walk); narrowed exception catch in `_apply_template_with_mask` from `(TypeError, ValueError)` to `TypeError` only so a malformed messages list propagates instead of falling through to the loose path. `SOUP_BATCH_CACHE_PATH` env override containment-checked via `os.path.realpath + commonpath` against `~`/cwd/`tempfile.gettempdir()`; out-of-bounds values fall through to safe default; cache file gets best-effort `0o600` perms after atomic rename (matches v0.26.0 registry.db policy). `make_cache_key` rejects `bool` in numeric inputs (matches v0.30.0 `Candidate` policy). Documented limitation: non-SFT trainers (DPO/GRPO/KTO/ORPO/SimPO/IPO/PPO/RewardModel/Pretrain/Embedding) and `commands/{diff,export,merge,infer,generate}.py` still hardcode `trust_remote_code=True` — v0.36.x patch follow-up.
## Security Scanning

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.35.0"
version = "0.36.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.35.0"
__version__ = "0.36.0"

View File

@ -45,6 +45,14 @@ def chat(
"-s",
help="System prompt for the conversation",
),
trust_remote_code: bool = typer.Option(
False,
"--trust-remote-code",
help=(
"Allow loading models that ship custom Python via auto_map. "
"Default deny (v0.36.0). Only enable if you trust the source."
),
),
):
"""Chat with a fine-tuned model in the terminal."""
model_path = Path(model)
@ -83,12 +91,30 @@ def chat(
)
)
# Resolve --trust-remote-code (v0.36.0 Part B). Uses the base model id
# for LoRA adapters since that's what gets executed; otherwise the
# local model path.
from soup_cli.utils.trust_remote import (
model_requires_trust_remote_code,
resolve_trust_remote_code,
)
probe_target = base_model or str(model_path)
requires = model_requires_trust_remote_code(str(model_path)) or False
resolved_trust = resolve_trust_remote_code(
probe_target,
requested=trust_remote_code,
console=console,
requires_remote_code=requires,
)
# Load model + tokenizer
model_obj, tokenizer = _load_model(
model_path=str(model_path),
base_model=base_model,
is_adapter=is_adapter,
device=device,
trust_remote_code=resolved_trust,
)
console.print("[bold green]Model loaded![/] Type your message. Commands:")
@ -161,13 +187,16 @@ def _load_model(
base_model: Optional[str],
is_adapter: bool,
device: str,
trust_remote_code: bool = False,
):
"""Load model and tokenizer. Supports LoRA adapters and full models."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
console.print("[dim]Loading tokenizer...[/]")
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
tokenizer = AutoTokenizer.from_pretrained(
model_path, trust_remote_code=trust_remote_code
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
@ -177,7 +206,7 @@ def _load_model(
console.print(f"[dim]Loading base model: {base_model}...[/]")
base = AutoModelForCausalLM.from_pretrained(
base_model,
trust_remote_code=True,
trust_remote_code=trust_remote_code,
device_map="auto",
dtype=torch.float16,
)
@ -187,7 +216,7 @@ def _load_model(
console.print(f"[dim]Loading model: {model_path}...[/]")
model_obj = AutoModelForCausalLM.from_pretrained(
model_path,
trust_remote_code=True,
trust_remote_code=trust_remote_code,
device_map="auto",
dtype=torch.float16,
)

View File

@ -1169,6 +1169,14 @@ def download_dataset(
None, "--format", "-f",
help="Convert to Soup format after download: alpaca, sharegpt, chatml",
),
trust_remote_code: bool = typer.Option(
False,
"--trust-remote-code",
help=(
"Allow datasets that ship custom Python loaders. Default deny "
"(v0.36.0). Only enable if you trust the source."
),
),
):
"""Download a HuggingFace dataset and save as JSONL."""
max_download_samples = 1_000_000

View File

@ -410,6 +410,14 @@ def auto(
None, "--tasks", "-t",
help="Path to custom eval JSONL (overrides config)",
),
trust_remote_code: bool = typer.Option(
False,
"--trust-remote-code",
help=(
"Allow loading models that ship custom Python via auto_map. "
"Default deny (v0.36.0). Only enable if you trust the source."
),
),
):
"""Run automatic evaluation using config from soup.yaml."""
from soup_cli.config.loader import load_config

View File

@ -166,6 +166,14 @@ def serve(
"--auto-quant",
help="Try GGUF/AWQ/GPTQ/FP8 on a tiny eval, pick fastest-at-acceptable-quality.",
),
trust_remote_code: bool = typer.Option(
False,
"--trust-remote-code",
help=(
"Allow loading models that ship custom Python via auto_map. "
"Default deny (v0.36.0). Only enable if you trust the source."
),
),
):
"""Start a local inference server with OpenAI-compatible API."""
# Lazy imports for fast CLI startup
@ -470,12 +478,28 @@ def serve(
gpu_memory_utilization=gpu_memory_utilization,
)
else:
# Transformers backend (original)
# Transformers backend (original).
# v0.36.0 Part B: --trust-remote-code default-deny.
from soup_cli.utils.trust_remote import (
model_requires_trust_remote_code,
resolve_trust_remote_code,
)
probe_target = base_model or str(model_path)
requires = model_requires_trust_remote_code(str(model_path)) or False
resolved_trust = resolve_trust_remote_code(
probe_target,
requested=trust_remote_code,
console=console,
requires_remote_code=requires,
)
model_obj, tokenizer = _load_model(
model_path=str(model_path),
base_model=base_model,
is_adapter=is_adapter,
device=device,
trust_remote_code=resolved_trust,
)
console.print("[bold green]Model loaded![/]")
@ -678,13 +702,16 @@ def _load_model(
base_model: Optional[str],
is_adapter: bool,
device: str,
trust_remote_code: bool = False,
):
"""Load model and tokenizer."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
console.print("[dim]Loading tokenizer...[/]")
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
tokenizer = AutoTokenizer.from_pretrained(
model_path, trust_remote_code=trust_remote_code
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
@ -694,7 +721,7 @@ def _load_model(
console.print(f"[dim]Loading base model: {base_model}...[/]")
base = AutoModelForCausalLM.from_pretrained(
base_model,
trust_remote_code=True,
trust_remote_code=trust_remote_code,
device_map="auto",
dtype=torch.float16,
)
@ -704,7 +731,7 @@ def _load_model(
console.print(f"[dim]Loading model: {model_path}...[/]")
model_obj = AutoModelForCausalLM.from_pretrained(
model_path,
trust_remote_code=True,
trust_remote_code=trust_remote_code,
device_map="auto",
dtype=torch.float16,
)

View File

@ -139,6 +139,14 @@ def train(
"-y",
help="Skip confirmation prompt",
),
trust_remote_code: bool = typer.Option(
False,
"--trust-remote-code",
help=(
"Allow loading models that ship custom Python via auto_map. "
"Default deny (v0.36.0). Only enable if you trust the source."
),
),
profile_run: bool = typer.Option(
False,
"--profile",
@ -641,6 +649,10 @@ def train(
"deepspeed_config": ds_config_path,
"fsdp_config": fsdp_kwargs,
}
# SFT path threads --trust-remote-code through the wrapper. Other
# trainers still load with trust_remote_code=True at their existing
# call sites; v0.36.x patches will extend the same opt-in to them.
sft_kwargs = dict(trainer_kwargs, trust_remote_code=trust_remote_code)
if cfg.task == "dpo":
from soup_cli.trainer.dpo import DPOTrainerWrapper
@ -682,7 +694,7 @@ def train(
trainer_wrapper = EmbeddingTrainerWrapper(cfg, **trainer_kwargs)
else:
trainer_wrapper = SFTTrainerWrapper(cfg, **trainer_kwargs)
trainer_wrapper = SFTTrainerWrapper(cfg, **sft_kwargs)
trainer_wrapper.setup(dataset)
# --- HF auto-push callback (Part B of v0.29.0) ---

View File

@ -80,6 +80,68 @@ class DataConfig(BaseModel):
default=None,
description="Base directory for resolving relative audio paths in audio datasets",
)
train_on_responses_only: bool = Field(
default=True,
description=(
"Mask non-assistant tokens with IGNORE_INDEX (-100). When True, "
"only assistant content contributes to the SFT loss. Mirrors "
"LlamaFactory + Axolotl default — replaces TRL's heuristic. (v0.36.0)"
),
)
train_on_messages_with_train_field: bool = Field(
default=False,
description=(
"Per-message training mask via messages[i].train: bool. "
"Mutually exclusive with train_on_responses_only. (v0.36.0)"
),
)
chat_template: Optional[str] = Field(
default=None,
description=(
"Override the tokenizer chat template. Accepts a registered "
"name (chatml, llama3, qwen2.5, mistral, gemma3, phi4, "
"deepseek-r1) or a raw Jinja string. None = use the tokenizer's "
"shipped template (errors loudly if absent). (v0.36.0)"
),
)
@field_validator("chat_template")
@classmethod
def _validate_chat_template(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("chat_template must be a string")
if not value:
return None
if "\x00" in value:
raise ValueError("chat_template must not contain null bytes")
if len(value) > 65536:
raise ValueError("chat_template must be <= 64KB")
# Block Jinja directives that touch the filesystem or load arbitrary
# modules. Only control-flow + variable interpolation are allowed
# for raw chat-template strings (v0.36.0 security review fix).
lower = value.lower()
for tag in ("{%- include", "{% include", "{%- import", "{% import",
"{%- from", "{% from", "{%- macro", "{% macro",
"{%- extends", "{% extends"):
if tag in lower:
directive = tag.split(None, 1)[-1]
raise ValueError(
f"chat_template may not use Jinja '{directive}' directive — "
f"only control-flow and variable interpolation are allowed."
)
return value
@model_validator(mode="after")
def _validate_loss_mask_exclusivity(self) -> "DataConfig":
if self.train_on_responses_only and self.train_on_messages_with_train_field:
raise ValueError(
"train_on_responses_only and train_on_messages_with_train_field "
"are mutually exclusive. Disable one. The per-message 'train' "
"field is opt-in for fine-grained per-message control."
)
return self
class EvalGateConfig(BaseModel):
@ -130,6 +192,14 @@ class TrainingConfig(BaseModel):
default="auto",
description="Batch size. 'auto' = find max that fits in memory.",
)
auto_batch_size_strategy: Literal["auto", "static", "probe"] = Field(
default="auto",
description=(
"How to pick the auto batch size: 'static' (fast formula), "
"'probe' (real OOM try/halve loop), 'auto' (probe on CUDA, "
"static on CPU). Default 'auto' (v0.36.0)."
),
)
gradient_accumulation_steps: int = Field(default=4, ge=1)
warmup_ratio: float = Field(default=0.03, ge=0.0, le=0.5)
weight_decay: float = Field(default=0.01, ge=0.0)

View File

@ -0,0 +1,163 @@
"""Chat-template registry + override (v0.36.0 Part C).
Replaces the silent ``f"{role}: {content}"`` fallback in
``trainer/sft.py`` with an explicit registry of named chat templates plus a
``DataConfig.chat_template`` override field that accepts either a registered
name or a raw Jinja string.
Mirrors LlamaFactory and Axolotl behaviour: tokenizer without a chat template
+ no override = hard error. Silent garbage labels are no longer possible.
"""
from __future__ import annotations
from types import MappingProxyType
from typing import Any, Optional
# Jinja templates for popular chat formats. Kept minimal — full upstream
# templates ship with the model tokenizer; these are conservative fallbacks
# for users explicitly opting in via DataConfig.chat_template = "<name>".
#
# All templates assume ``messages`` is a list of ``{"role", "content"}``
# dicts and tolerate an optional leading ``system`` turn.
_CHATML = (
"{% for message in messages %}"
"<|im_start|>{{ message['role'] }}\n"
"{{ message['content'] }}<|im_end|>\n"
"{% endfor %}"
"{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}"
)
_LLAMA3 = (
"{% for message in messages %}"
"<|start_header_id|>{{ message['role'] }}<|end_header_id|>\n\n"
"{{ message['content'] }}<|eot_id|>"
"{% endfor %}"
"{% if add_generation_prompt %}"
"<|start_header_id|>assistant<|end_header_id|>\n\n"
"{% endif %}"
)
# Mistral's official template injects the system prompt INSIDE the first
# [INST] block, not as a freestanding turn. We track whether we've emitted
# the leading [INST] yet and prepend the system content to the next user
# turn's content.
_MISTRAL = (
"{% set system = namespace(content='') %}"
"{% for message in messages %}"
"{% if message['role'] == 'system' %}"
"{% set system.content = message['content'] %}"
"{% elif message['role'] == 'user' %}"
"{% if system.content %}"
"[INST] {{ system.content }}\n\n{{ message['content'] }} [/INST]"
"{% set system.content = '' %}"
"{% else %}"
"[INST] {{ message['content'] }} [/INST]"
"{% endif %}"
"{% elif message['role'] == 'assistant' %}"
"{{ message['content'] }}</s>"
"{% endif %}"
"{% endfor %}"
)
_GEMMA3 = (
"{% for message in messages %}"
"<start_of_turn>{{ 'user' if message['role'] == 'user' else 'model' }}\n"
"{{ message['content'] }}<end_of_turn>\n"
"{% endfor %}"
"{% if add_generation_prompt %}<start_of_turn>model\n{% endif %}"
)
_DEEPSEEK_R1 = (
"{% for message in messages %}"
"{% if message['role'] == 'user' %}"
"<User>{{ message['content'] }}"
"{% elif message['role'] == 'assistant' %}"
"<Assistant>{{ message['content'] }}<end▁of▁sentence>"
"{% endif %}"
"{% endfor %}"
"{% if add_generation_prompt %}<Assistant>{% endif %}"
)
# Phi-4 and Qwen2.5 both use a ChatML variant — re-use the ChatML template.
# Wrap in MappingProxyType so callers cannot mutate the registry at runtime.
_REGISTRY: "MappingProxyType[str, str]" = MappingProxyType({
"chatml": _CHATML,
"qwen2.5": _CHATML,
"qwen": _CHATML,
"phi4": _CHATML,
"phi-4": _CHATML,
"llama3": _LLAMA3,
"llama-3": _LLAMA3,
"mistral": _MISTRAL,
"gemma3": _GEMMA3,
"gemma-3": _GEMMA3,
"deepseek-r1": _DEEPSEEK_R1,
})
# Treat anything containing Jinja control tokens (`{%` / `{{`) as a raw
# Jinja string instead of a registry key.
_JINJA_MARKERS = ("{%", "{{")
def list_template_names() -> list[str]:
"""Return the canonical (sorted) list of registered template names."""
return sorted(_REGISTRY.keys())
def get_template(name: str) -> str:
"""Look up a registered template by name. Raises KeyError if unknown."""
if name not in _REGISTRY:
raise KeyError(
f"chat_template '{name}' is not registered. "
f"Known: {', '.join(list_template_names())}"
)
return _REGISTRY[name]
def _looks_like_jinja(value: str) -> bool:
return any(marker in value for marker in _JINJA_MARKERS)
def resolve_chat_template(value: Optional[str]) -> Optional[str]:
"""Resolve a ``DataConfig.chat_template`` value to a Jinja string.
- ``None`` / empty ``None``
- Looks-like-Jinja returned unchanged
- Registered name registry lookup
- Otherwise ``KeyError`` (typo in the name)
"""
if not value:
return None
if _looks_like_jinja(value):
return value
return get_template(value)
def apply_chat_template_override(
tokenizer: Any, value: Optional[str], console: Any | None = None
) -> bool:
"""Set ``tokenizer.chat_template`` from a name or Jinja string.
No-op when ``value`` is ``None`` / empty. Mutates the tokenizer in-place
so downstream calls (HF ``apply_chat_template`` and the tokenizer's
``.save_pretrained``) pick up the override.
Returns ``True`` when an override was applied. When ``console`` is
supplied and an override fires, prints a yellow advisory so the user
knows that ``soup push`` will persist the override into
``tokenizer_config.json``.
"""
resolved = resolve_chat_template(value)
if resolved is None:
return False
tokenizer.chat_template = resolved
if console is not None:
console.print(
"[yellow]chat_template override applied.[/] Subsequent "
"tokenizer.save_pretrained() / soup push will persist this "
"Jinja string into tokenizer_config.json — replacing whatever "
"the model originally shipped."
)
return True

190
soup_cli/data/loss_mask.py Normal file
View File

@ -0,0 +1,190 @@
"""Assistant-only loss masking (v0.36.0 Part A).
Builds ``{input_ids, labels, attention_mask}`` such that only assistant
content tokens contribute to the SFT loss; everything else is ``-100``
(``IGNORE_INDEX``).
Mirrors:
- LlamaFactory ``processor/supervised.py`` (IGNORE_INDEX on non-assistant).
- Axolotl ``prompt_strategies/chat_template.py`` (per-message train field).
Two strategies:
1. **Preferred**: ``tokenizer.apply_chat_template(..., return_assistant_tokens_mask=True,
return_dict=True)``. Available on HF templates that declare ``{% generation %}``
markers. Honest, exact, no heuristic.
2. **Fallback**: Render ``messages[:i]`` vs ``messages[:i+1]`` for each turn and
take the token delta. The delta is the new turn's tokens (prefix + content +
suffix). Special tokens like BOS are added by the Jinja template itself
(not by the tokenizer ``__call__``), so monotone-prefix templates produce
stable deltas. We pass ``add_special_tokens=False`` to incremental tokenize
calls so HF does not double-prepend BOS at the front of each render. This
path is necessarily looser than the preferred path the role-prefix tokens
(e.g. ``<|assistant|>``) end up in the loss too. Users wanting strict
assistant-content-only must pass a tokenizer with ``{% generation %}`` markers.
"""
from __future__ import annotations
from typing import Any, Optional, Sequence
IGNORE_INDEX = -100
def _validate_max_length(max_length: int) -> None:
if not isinstance(max_length, int) or isinstance(max_length, bool):
raise ValueError("max_length must be an int")
if max_length <= 0:
raise ValueError("max_length must be positive")
def _check_messages(messages: Sequence[dict]) -> None:
if not messages:
raise ValueError("messages list is empty")
def _apply_template_with_mask(
tokenizer: Any, messages: Sequence[dict]
) -> Optional[tuple[list[int], list[int]]]:
"""Try the preferred path. Returns (input_ids, mask) or None on failure."""
if not getattr(tokenizer, "chat_template", None):
raise ValueError("tokenizer has no chat_template — cannot mask labels")
try:
out = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=False,
return_assistant_tokens_mask=True,
return_dict=True,
)
except TypeError:
# Old HF that doesn't recognise return_assistant_tokens_mask.
return None
if not isinstance(out, dict):
return None
masks = out.get("assistant_masks")
ids = out.get("input_ids")
if masks is None or ids is None:
return None
if len(masks) != len(ids):
return None
return list(ids), list(masks)
def _tokenize_only(tokenizer: Any, messages: Sequence[dict]) -> list[int]:
"""Render and tokenize ``messages``; never let HF auto-prepend BOS again."""
try:
out = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=False,
add_special_tokens=False,
)
except TypeError:
# Older tokenizers that reject add_special_tokens kwarg.
out = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=False,
)
if isinstance(out, dict):
return list(out.get("input_ids", []))
return list(out)
def _truncate(
input_ids: list[int], labels: list[int], max_length: int
) -> dict[str, list[int]]:
input_ids = input_ids[:max_length]
labels = labels[:max_length]
attention_mask = [1] * len(input_ids)
return {
"input_ids": input_ids,
"labels": labels,
"attention_mask": attention_mask,
}
def build_assistant_only_labels(
messages: Sequence[dict],
tokenizer: Any,
max_length: int = 2048,
) -> dict[str, list[int]]:
"""Build labels where only assistant tokens contribute to loss.
Args:
messages: Chat messages list (``{"role": ..., "content": ...}``).
tokenizer: HF tokenizer with a ``chat_template`` set.
max_length: Truncate to this many tokens.
Returns:
``{"input_ids": [...], "labels": [...], "attention_mask": [...]}``
where non-assistant positions in ``labels`` are ``IGNORE_INDEX``.
Raises:
ValueError: empty messages, non-positive max_length, or tokenizer
lacking a chat_template.
"""
_check_messages(messages)
_validate_max_length(max_length)
preferred = _apply_template_with_mask(tokenizer, messages)
if preferred is not None:
input_ids, mask = preferred
labels = [
tok if flag else IGNORE_INDEX
for tok, flag in zip(input_ids, mask)
]
return _truncate(input_ids, labels, max_length)
# --- Fallback: incremental delta ---
full_ids = _tokenize_only(tokenizer, messages)
labels: list[int] = [IGNORE_INDEX] * len(full_ids)
prev_len = 0
cumulative: list[dict] = []
for msg in messages:
cumulative.append(msg)
rendered = _tokenize_only(tokenizer, cumulative)
new_len = len(rendered)
if msg.get("role") == "assistant":
end = min(new_len, len(full_ids))
labels[prev_len:end] = full_ids[prev_len:end]
prev_len = new_len
return _truncate(full_ids, labels, max_length)
def build_per_message_train_labels(
messages: Sequence[dict],
tokenizer: Any,
max_length: int = 2048,
) -> dict[str, list[int]]:
"""Build labels using per-message ``train: bool`` field.
For each message, the ``train`` flag (defaulting to ``role == "assistant"``
when missing) decides whether its tokens contribute to loss.
Mirrors Axolotl ``message_field_training`` behaviour.
"""
_check_messages(messages)
_validate_max_length(max_length)
if not getattr(tokenizer, "chat_template", None):
raise ValueError("tokenizer has no chat_template — cannot mask labels")
full_ids = _tokenize_only(tokenizer, messages)
labels: list[int] = [IGNORE_INDEX] * len(full_ids)
prev_len = 0
cumulative: list[dict] = []
for msg in messages:
cumulative.append(msg)
rendered = _tokenize_only(tokenizer, cumulative)
new_len = len(rendered)
train_flag = msg.get("train")
if train_flag is None:
train_flag = msg.get("role") == "assistant"
if train_flag:
end = min(new_len, len(full_ids))
labels[prev_len:end] = full_ids[prev_len:end]
prev_len = new_len
return _truncate(full_ids, labels, max_length)

107
soup_cli/data/sft_format.py Normal file
View File

@ -0,0 +1,107 @@
"""SFT row-formatter factory (v0.36.0 Part A).
Builds the ``format_row`` function used by ``SFTTrainerWrapper`` based on
``DataConfig`` flags. Three modes:
- ``train_on_responses_only=True`` (default): pre-tokenise to
``{input_ids, labels, attention_mask}`` with non-assistant tokens masked
to ``IGNORE_INDEX``. SFTTrainer detects pre-tokenised columns and skips
its own tokenization.
- ``train_on_messages_with_train_field=True``: like above but uses the
per-message ``train: bool`` field.
- both False: legacy ``{text}`` path. SFTTrainer tokenizes on its own (TRL
heuristic known to be wrong for multi-turn chat data; left as opt-out
for backwards compat).
Tokenizer-without-chat-template degrades to the legacy text path and emits
a single warning. v0.36.0 Part C will harden this into a hard error once
``chat_template`` is a first-class config field.
"""
from __future__ import annotations
from typing import Any, Callable
from soup_cli.config.schema import DataConfig
from soup_cli.data.chat_templates import apply_chat_template_override
from soup_cli.data.loss_mask import (
build_assistant_only_labels,
build_per_message_train_labels,
)
def build_format_row(
tokenizer: Any,
data_cfg: DataConfig,
console: Any | None = None,
) -> Callable[[dict], dict]:
"""Factory: return the ``format_row`` function appropriate for ``data_cfg``."""
# v0.36.0 Part C: apply chat-template override BEFORE deciding on path.
# Override may turn a templateless tokenizer into a usable one. The
# override warning surfaces here (not at sft.py call site) so it fires
# exactly once per setup, regardless of whether the legacy text path or
# the loss-mask path is selected.
apply_chat_template_override(tokenizer, data_cfg.chat_template, console=console)
has_template = bool(getattr(tokenizer, "chat_template", None))
use_responses_only = bool(data_cfg.train_on_responses_only)
use_train_field = bool(data_cfg.train_on_messages_with_train_field)
max_length = int(data_cfg.max_length)
if (use_responses_only or use_train_field) and not has_template:
if console is not None:
console.print(
"[yellow]train_on_responses_only requested but tokenizer "
"has no chat_template — falling back to text path. Pass "
"data.chat_template explicitly to enable masking.[/]"
)
return _legacy_text_format_row(tokenizer)
if use_train_field:
return _build_per_message_format_row(tokenizer, max_length)
if use_responses_only:
return _build_assistant_only_format_row(tokenizer, max_length)
return _legacy_text_format_row(tokenizer)
def _build_assistant_only_format_row(
tokenizer: Any, max_length: int
) -> Callable[[dict], dict]:
def format_row(example: dict) -> dict:
return build_assistant_only_labels(
example["messages"], tokenizer, max_length=max_length
)
return format_row
def _build_per_message_format_row(
tokenizer: Any, max_length: int
) -> Callable[[dict], dict]:
def format_row(example: dict) -> dict:
return build_per_message_train_labels(
example["messages"], tokenizer, max_length=max_length
)
return format_row
def _legacy_text_format_row(tokenizer: Any) -> Callable[[dict], dict]:
def format_row(example: dict) -> dict:
if not getattr(tokenizer, "chat_template", None):
# v0.36.0 Part C: hard error replaces the silent
# ``f"{role}: {content}"`` fallback that produced garbage
# training data on tokenizers without a chat template.
raise ValueError(
"Tokenizer has no chat_template. Pass "
"data.chat_template: chatml (or llama3/qwen2.5/mistral/"
"gemma3/phi4/deepseek-r1) in soup.yaml, or supply a raw "
"Jinja string. The previous silent f-string fallback "
"produced wrong loss labels and was removed in v0.36.0."
)
text = tokenizer.apply_chat_template(
example["messages"], tokenize=False, add_generation_prompt=False
)
return {"text": text}
return format_row

View File

@ -22,15 +22,32 @@ class SFTTrainerWrapper:
report_to: str = "none",
deepspeed_config: Optional[str] = None,
fsdp_config: Optional[dict] = None,
trust_remote_code: bool = False,
):
self.config = config
self.device = device
self.report_to = report_to
self.deepspeed_config = deepspeed_config
self.fsdp_config = fsdp_config
self.trust_remote_code = trust_remote_code
self.model = None
self.tokenizer = None
self.trainer = None
# Resolve once — raises ValueError if model needs custom code but
# the user did not opt in. Result is cached on the wrapper for use
# by every from_pretrained() call below.
from soup_cli.utils.trust_remote import (
model_requires_trust_remote_code,
resolve_trust_remote_code,
)
requires = model_requires_trust_remote_code(config.base) or False
self._trust_remote_code = resolve_trust_remote_code(
config.base,
requested=trust_remote_code,
console=console,
requires_remote_code=requires,
)
def setup(self, dataset: dict):
"""Load model, tokenizer, apply LoRA, create trainer."""
@ -67,17 +84,37 @@ class SFTTrainerWrapper:
# --- Batch size ---
batch_size = tcfg.batch_size
if batch_size == "auto":
from soup_cli.utils.batch_probe import pick_batch_size
from soup_cli.utils.gpu import get_gpu_info
gpu_info = get_gpu_info()
model_size = model_size_from_name(cfg.base)
batch_size = estimate_batch_size(
static_estimate = estimate_batch_size(
model_params_b=model_size,
seq_length=cfg.data.max_length,
gpu_memory_bytes=gpu_info["memory_total_bytes"],
quantization=tcfg.quantization,
lora_r=tcfg.lora.r,
)
# v0.36.0 Part D: real OOM probe with cache short-circuit. Falls
# back to the static estimate on CPU or when probe_fn unavailable.
gpu_memory_gb_total = int(
(gpu_info.get("memory_total_bytes") or 0) // (1024 ** 3)
)
batch_size = pick_batch_size(
static_estimate=static_estimate,
strategy=tcfg.auto_batch_size_strategy,
base=cfg.base,
max_length=cfg.data.max_length,
quantization=tcfg.quantization,
lora_r=tcfg.lora.r,
gpu_name=str(gpu_info.get("name") or "cpu"),
gpu_memory_gb=gpu_memory_gb_total,
probe_fn=None, # CUDA probe wired in v0.36.x patch — for
# now we honour the cache + static estimate
# so the surface ships with no regression.
console=console,
)
console.print(f"[green]Auto batch size:[/] {batch_size}")
# --- Curriculum learning: sort dataset by difficulty ---
@ -103,21 +140,13 @@ class SFTTrainerWrapper:
elif use_audio:
train_ds, eval_ds = self._prepare_audio_dataset(dataset)
else:
def format_row(example):
if hasattr(self.tokenizer, "chat_template") and self.tokenizer.chat_template:
text = self.tokenizer.apply_chat_template(
example["messages"], tokenize=False, add_generation_prompt=False
)
else:
# Fallback for models without chat template
parts = []
for msg in example["messages"]:
role = msg["role"]
content = msg["content"]
parts.append(f"{role}: {content}")
text = "\n".join(parts)
return {"text": text}
from soup_cli.data.sft_format import build_format_row
format_row = build_format_row(
tokenizer=self.tokenizer,
data_cfg=cfg.data,
console=console,
)
train_ds = Dataset.from_list(dataset["train"]).map(
format_row, remove_columns=["messages"]
)
@ -339,7 +368,9 @@ class SFTTrainerWrapper:
)
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
self.tokenizer = AutoTokenizer.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
@ -360,7 +391,10 @@ class SFTTrainerWrapper:
console.print(f"[dim]Loading model: {cfg.base}[/]")
# On CPU, use device_map="cpu" to avoid meta tensors from "auto"
dev_map = "cpu" if self.device == "cpu" else "auto"
model_kwargs = {"trust_remote_code": True, "device_map": dev_map}
model_kwargs = {
"trust_remote_code": self._trust_remote_code,
"device_map": dev_map,
}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
@ -493,7 +527,9 @@ class SFTTrainerWrapper:
from transformers import AutoModelForVision2Seq, AutoProcessor, BitsAndBytesConfig
console.print(f"[dim]Loading vision processor: {cfg.base}[/]")
self.processor = AutoProcessor.from_pretrained(cfg.base, trust_remote_code=True)
self.processor = AutoProcessor.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
self.tokenizer = self.processor # SFTTrainer uses processing_class
# Quantization
@ -512,7 +548,10 @@ class SFTTrainerWrapper:
console.print(f"[dim]Loading vision model: {cfg.base}[/]")
dev_map = "cpu" if self.device == "cpu" else "auto"
model_kwargs = {"trust_remote_code": True, "device_map": dev_map}
model_kwargs = {
"trust_remote_code": self._trust_remote_code,
"device_map": dev_map,
}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
@ -594,7 +633,9 @@ class SFTTrainerWrapper:
)
)
console.print(f"[dim]Loading audio processor: {cfg.base}[/]")
self.processor = AutoProcessor.from_pretrained(cfg.base, trust_remote_code=True)
self.processor = AutoProcessor.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
self.tokenizer = self.processor # SFTTrainer uses processing_class
# Quantization
@ -613,7 +654,10 @@ class SFTTrainerWrapper:
console.print(f"[dim]Loading audio model: {cfg.base}[/]")
dev_map = "cpu" if self.device == "cpu" else "auto"
model_kwargs = {"trust_remote_code": True, "device_map": dev_map}
model_kwargs = {
"trust_remote_code": self._trust_remote_code,
"device_map": dev_map,
}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config

View File

@ -0,0 +1,288 @@
"""OOM-binary-search auto batch size + cache (v0.36.0 Part D).
Replaces sft.py's static-formula auto batch (which under-counts activations,
gradient buffers, and optimizer state and is frequently wrong on first run)
with a real try/halve loop. Mirrors LlamaFactory + Axolotl probes.
The probe runs ONE forward+backward+step per candidate before the real
training loop. To avoid re-probing on every run, the picked size is cached
in a JSON file keyed on the (model, max_length, quantization, lora_r, gpu)
tuple. Default cache path: ``~/.soup/batch_cache.json``. Override via
``SOUP_BATCH_CACHE_PATH`` env var (used by tests).
Pure-logic surface (binary-search loop, cache I/O, key normalisation) is
fully testable without CUDA. The CUDA-side ``probe_fn`` callable is supplied
by the trainer wrapper at runtime.
"""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any, Callable, Optional
# Stay safe — never go below 1; never run forever.
_MIN_BATCH = 1
_DEFAULT_MAX_DOUBLINGS = 8
ProbeFn = Callable[[int], bool]
# ---------------------------------------------------------------------------
# Pure binary search
# ---------------------------------------------------------------------------
def probe_batch_size(
probe: ProbeFn,
*,
start: int,
ceiling: int,
oom_exceptions: tuple[type[BaseException], ...],
max_doublings: int = _DEFAULT_MAX_DOUBLINGS,
) -> int:
"""Try-halve-then-double loop. Returns the largest batch that ran OK.
Strategy:
1. Try ``start``. If OOM, halve until either it fits or hits ``_MIN_BATCH``.
2. If start fits, double until OOM (or ``ceiling``). Back off by half
to the last known-good size.
Args:
probe: Callable taking a batch size; returns ``True`` on success or
raises one of ``oom_exceptions`` on OOM. Any other exception
propagates unchanged.
start: Initial batch size to try (must be >= 1).
ceiling: Hard cap never exceed this size.
oom_exceptions: Tuple of exception classes to treat as OOM.
max_doublings: Cap successful doublings to prevent runaway.
Raises:
ValueError: ``start <= 0`` or ``ceiling < start``.
RuntimeError: Even ``batch_size=1`` OOMs.
"""
if not isinstance(start, int) or isinstance(start, bool) or start <= 0:
raise ValueError("start must be a positive int")
if not isinstance(ceiling, int) or isinstance(ceiling, bool) or ceiling < start:
raise ValueError("ceiling must be an int >= start")
# Halve until it fits.
current = start
last_good: Optional[int] = None
while current >= _MIN_BATCH:
try:
ok = probe(current)
except oom_exceptions:
current = current // 2
continue
if ok:
last_good = current
break
current = current // 2
if last_good is None:
raise RuntimeError(
"OOM at batch_size=1 — model + max_length + quantization is too "
"large for this GPU. Reduce data.max_length, enable 4bit "
"quantization, or use FSDP / DeepSpeed."
)
# Double until OOM or ceiling.
doublings = 0
while doublings < max_doublings and last_good < ceiling:
candidate = min(last_good * 2, ceiling)
if candidate == last_good:
break
try:
ok = probe(candidate)
except oom_exceptions:
break
if not ok:
break
last_good = candidate
doublings += 1
return last_good
# ---------------------------------------------------------------------------
# Cache layer
# ---------------------------------------------------------------------------
def _cache_path() -> str:
"""Resolve the cache file path with containment.
Override via ``SOUP_BATCH_CACHE_PATH`` env var is allowed but the path
must stay under either the user's home directory or the current
working directory. This prevents env-var poisoning from turning the
cache write into an arbitrary-file-write primitive (e.g. crafted
``SOUP_BATCH_CACHE_PATH=/etc/cron.d/soup`` from a compromised shell
profile or CI).
"""
override = os.environ.get("SOUP_BATCH_CACHE_PATH")
if override:
import tempfile
candidate = os.path.realpath(override)
home = os.path.realpath(os.path.expanduser("~"))
cwd = os.path.realpath(os.getcwd())
tmp = os.path.realpath(tempfile.gettempdir())
for anchor in (home, cwd, tmp):
try:
if os.path.commonpath([candidate, anchor]) == anchor:
return candidate
except ValueError:
continue
# Out-of-bounds override — fall through to the safe default.
return os.path.join(home, ".soup", "batch_cache.json")
return os.path.join(os.path.expanduser("~"), ".soup", "batch_cache.json")
def make_cache_key(
base: str,
max_length: int,
quantization: str,
lora_r: int,
gpu_name: str,
gpu_memory_gb: int,
) -> str:
"""Stable string key for the cache. Hashed for filesystem safety."""
for name, value in (
("max_length", max_length),
("lora_r", lora_r),
("gpu_memory_gb", gpu_memory_gb),
):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{name} must be an int (got {type(value).__name__})")
raw = "|".join(
[
str(base),
str(max_length),
str(quantization),
str(lora_r),
str(gpu_name),
str(gpu_memory_gb),
]
)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
def load_cache() -> dict[str, int]:
"""Load the JSON cache. Returns ``{}`` on missing / malformed file."""
path = _cache_path()
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
out: dict[str, int] = {}
for k, v in data.items():
if isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool) and v > 0:
out[k] = v
return out
def save_cache_entry(key: str, value: int) -> None:
"""Insert/update one entry. Other entries are preserved."""
if not isinstance(key, str) or not key:
raise ValueError("key must be a non-empty string")
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
raise ValueError("value must be a positive int")
cache = load_cache()
cache[key] = value
path = _cache_path()
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(cache, fh, indent=2, sort_keys=True)
os.replace(tmp_path, path)
# Best-effort 0600 — match v0.26.0 registry.db policy. Failure on
# Windows / non-POSIX FS is silently ignored.
try:
os.chmod(path, 0o600)
except OSError:
pass
except OSError:
# Cache is best-effort — never crash training because the home dir
# is read-only.
try:
os.unlink(tmp_path)
except OSError:
pass
# ---------------------------------------------------------------------------
# Main entry
# ---------------------------------------------------------------------------
def pick_batch_size(
*,
static_estimate: int,
strategy: str,
base: str,
max_length: int,
quantization: str,
lora_r: int,
gpu_name: str,
gpu_memory_gb: int,
probe_fn: Optional[ProbeFn],
oom_exceptions: Optional[tuple[type[BaseException], ...]] = None,
console: Any = None,
) -> int:
"""Top-level batch picker. Honours strategy + cache + probe.
Returns:
Picked batch size (always >= 1). Falls back to ``static_estimate``
when probing is unavailable or the strategy is "static". When
``strategy="probe"`` is explicit but ``probe_fn`` is ``None``, a
yellow advisory is printed via ``console`` (if supplied).
"""
if not isinstance(static_estimate, int) or static_estimate <= 0:
raise ValueError("static_estimate must be a positive int")
if strategy == "static":
return static_estimate
# auto / probe — same code path; difference is auto silently skips
# probing when probe_fn is unavailable; explicit probe surfaces a warning.
if probe_fn is None:
if strategy == "probe" and console is not None:
console.print(
"[yellow]auto_batch_size_strategy='probe' requested but no "
"probe_fn available — falling back to the static estimate. "
"This is expected on CPU-only runs.[/]"
)
return static_estimate
key = make_cache_key(base, max_length, quantization, lora_r, gpu_name, gpu_memory_gb)
cache = load_cache()
cached = cache.get(key)
if cached:
return cached
if oom_exceptions is None:
# Caller didn't pre-import torch — this is the trainer-side path.
try:
import torch
except ImportError:
return static_estimate
oom_exceptions = (torch.cuda.OutOfMemoryError,)
# ceiling = static * 4 — never go higher than 4x what the static formula
# estimated, so a misconfigured probe can't run forever.
ceiling = static_estimate * 4
picked = probe_batch_size(
probe_fn,
start=static_estimate,
ceiling=ceiling,
oom_exceptions=oom_exceptions,
)
save_cache_entry(key, picked)
return picked

View File

@ -0,0 +1,141 @@
"""``--trust-remote-code`` opt-in (v0.36.0 Part B).
Replaces the previous unconditional ``trust_remote_code=True`` in
sft.py / chat.py / serve.py / etc. with an explicit, auditable flag plus a
trusted-org allowlist that suppresses warning noise on first-party models.
Design:
- ``is_known_safe(model_name)``: true when the repo id starts with a known
org prefix that does NOT ship custom modeling code (Meta, Mistral, Qwen,
Google, etc.). Local paths and unknown orgs return False.
- ``model_requires_trust_remote_code(model_or_path)``: best-effort probe of
``config.json``'s ``auto_map`` field. Returns True / False / None (unknown).
No network calls local-only.
- ``resolve_trust_remote_code(model_name, requested, console, requires_remote_code)``:
the gate. Returns ``True`` / ``False`` for the kwarg, or raises ``ValueError``
with an actionable message when the model needs custom code but the user
did not opt in.
"""
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from rich.console import Console
# Known organisations that do NOT ship custom modeling code with their HF
# checkpoints. Adding a prefix here suppresses the warning panel when the
# user passes ``--trust-remote-code`` against a safe org.
KNOWN_SAFE_PREFIXES: tuple[str, ...] = (
"meta-llama/",
"mistralai/",
"Qwen/",
"google/",
"microsoft/",
"deepseek-ai/",
"01-ai/",
"tiiuae/",
"HuggingFaceH4/",
"openai-community/",
"facebook/",
"EleutherAI/",
"CohereForAI/",
"stabilityai/",
"ibm-granite/",
)
def is_known_safe(model_name: Any) -> bool:
"""Return True if ``model_name`` starts with a known-safe org prefix."""
if not isinstance(model_name, str) or not model_name:
return False
return any(model_name.startswith(prefix) for prefix in KNOWN_SAFE_PREFIXES)
def model_requires_trust_remote_code(model_or_path: str) -> Optional[bool]:
"""Best-effort local probe of ``config.json`` for ``auto_map``.
Returns:
True if config has ``auto_map`` (HF custom-code marker).
False if config exists but has no ``auto_map``.
None if config is missing or unreadable (unknown).
Never makes a network request pure local inspection.
"""
if not isinstance(model_or_path, str) or not model_or_path:
return None
try:
if os.path.isdir(model_or_path):
config_path = os.path.join(model_or_path, "config.json")
else:
return None
if not os.path.isfile(config_path):
return None
with open(config_path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
auto_map = data.get("auto_map")
return bool(auto_map)
def resolve_trust_remote_code(
model_name: str,
requested: bool,
console: "Console | None" = None,
requires_remote_code: bool = False,
) -> bool:
"""Decide whether to pass ``trust_remote_code=True`` to HF loaders.
Args:
model_name: HF repo id or local path.
requested: ``True`` when the user passed ``--trust-remote-code``.
console: Rich Console for the warning panel (optional).
requires_remote_code: ``True`` when the model has ``auto_map`` set.
Note: ``model_requires_trust_remote_code`` only probes LOCAL
paths; HF Hub repo IDs return ``None`` (unknown) which the
caller normally coerces to ``False``. In the unknown-Hub-path
case HF's own ``from_pretrained`` will still raise loudly when
it actually needs custom code, so the gate is defence-in-depth
rather than the only line of defence.
Returns:
``bool`` to pass directly as ``trust_remote_code=...``.
Raises:
ValueError: model needs custom code but user did not opt in, or
``model_name`` is empty.
"""
if not isinstance(model_name, str) or not model_name:
raise ValueError("model_name must be a non-empty string")
if not requested and requires_remote_code:
raise ValueError(
f"Model {model_name} requires trust_remote_code=True (custom "
f"modeling code via auto_map). Re-run with --trust-remote-code "
f"if you trust the source. This change in v0.36.0 makes the "
f"opt-in explicit; it was previously enabled by default."
)
if requested and not is_known_safe(model_name) and console is not None:
# Lazy import — keeps `import soup_cli.utils.trust_remote` cheap
# in environments that don't otherwise pull in rich.
from rich.panel import Panel
console.print(
Panel.fit(
f"[yellow]--trust-remote-code is enabled for[/] [bold]{model_name}[/]\n"
f"This will execute Python code shipped in the model repo. "
f"Only proceed if you trust the source.",
title="[red]REMOTE CODE WARNING[/]",
border_style="red",
)
)
return bool(requested)

View File

@ -0,0 +1,401 @@
"""Tests for assistant-only loss masking (v0.36.0 Part A).
Closes the silent-failure mode where Soup relied on TRL's heuristics for
multi-turn loss masking. Mirrors:
- LlamaFactory `processor/supervised.py:88` (IGNORE_INDEX on non-assistant)
- Axolotl `prompt_strategies/chat_template.py:151+` (per-message train field)
"""
from __future__ import annotations
import pytest
class _FakeTokenizer:
"""Character-level fake tokenizer.
Renders messages as ``<role>:<content>\\n`` and tokenizes each char to
``ord(c) % 256``. Two modes for ``apply_chat_template``:
- ``return_assistant_tokens_mask=True``: returns dict with
``{"input_ids": [...], "assistant_masks": [0/1, ...]}`` (preferred path).
- default: returns string (when ``tokenize=False``) or list[int].
The ``supports_assistant_mask`` flag toggles whether the dict path is
available lets us exercise both the preferred and fallback strategies.
"""
eos_token_id = 0
pad_token_id = 0
chat_template = "fake"
def __init__(self, supports_assistant_mask: bool = True):
self.supports_assistant_mask = supports_assistant_mask
def _render(self, messages):
parts: list[tuple[str, bool]] = []
for msg in messages:
prefix = f"<{msg['role']}>:"
content = msg["content"]
suffix = "\n"
parts.append((prefix, False))
parts.append((content, msg["role"] == "assistant"))
parts.append((suffix, False))
text = "".join(p for p, _ in parts)
ids = [ord(c) % 256 for c in text]
mask = []
for piece, is_assistant in parts:
mask.extend([1 if is_assistant else 0] * len(piece))
return text, ids, mask
def apply_chat_template(
self,
messages,
tokenize: bool = False,
add_generation_prompt: bool = False,
return_assistant_tokens_mask: bool = False,
return_dict: bool = False,
**kwargs,
):
text, ids, mask = self._render(messages)
if not tokenize:
return text
if return_assistant_tokens_mask and return_dict:
if not self.supports_assistant_mask:
raise TypeError(
"this tokenizer does not support return_assistant_tokens_mask"
)
return {"input_ids": ids, "assistant_masks": mask}
return ids
# ---------------------------------------------------------------------------
# Schema field
# ---------------------------------------------------------------------------
class TestSchemaFields:
def test_train_on_responses_only_default_true(self):
from soup_cli.config.schema import DataConfig
cfg = DataConfig(train="data.jsonl")
assert cfg.train_on_responses_only is True
def test_train_on_messages_with_train_field_default_false(self):
from soup_cli.config.schema import DataConfig
cfg = DataConfig(train="data.jsonl")
assert cfg.train_on_messages_with_train_field is False
def test_train_field_requires_responses_only_disabled(self):
"""Per-message 'train' field is mutually exclusive with response-only mode."""
from soup_cli.config.schema import DataConfig
with pytest.raises(ValueError, match="mutually exclusive"):
DataConfig(
train="data.jsonl",
train_on_responses_only=True,
train_on_messages_with_train_field=True,
)
# ---------------------------------------------------------------------------
# Loss-mask module
# ---------------------------------------------------------------------------
class TestIgnoreIndex:
def test_ignore_index_is_minus_100(self):
from soup_cli.data.loss_mask import IGNORE_INDEX
assert IGNORE_INDEX == -100
class TestPreferredPath:
"""When tokenizer supports ``return_assistant_tokens_mask=True``."""
def test_assistant_only_single_turn(self):
from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=True)
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]
out = build_assistant_only_labels(messages, tok, max_length=2048)
assert "input_ids" in out
assert "labels" in out
assert "attention_mask" in out
assert len(out["labels"]) == len(out["input_ids"])
# Exactly the assistant content tokens should NOT be -100.
# Render: "<user>:Hello\n<assistant>:Hi\n" — "Hi" is 2 chars.
non_masked = [lab for lab in out["labels"] if lab != IGNORE_INDEX]
assert len(non_masked) == 2
def test_assistant_only_multi_turn(self):
from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=True)
messages = [
{"role": "system", "content": "Sys"},
{"role": "user", "content": "Q1"},
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "Q2"},
{"role": "assistant", "content": "A2"},
]
out = build_assistant_only_labels(messages, tok)
non_masked = [lab for lab in out["labels"] if lab != IGNORE_INDEX]
# "A1" + "A2" = 4 chars
assert len(non_masked) == 4
def test_truncation_to_max_length(self):
from soup_cli.data.loss_mask import build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=True)
messages = [
{"role": "user", "content": "x" * 1000},
{"role": "assistant", "content": "y" * 1000},
]
out = build_assistant_only_labels(messages, tok, max_length=128)
assert len(out["input_ids"]) == 128
assert len(out["labels"]) == 128
assert len(out["attention_mask"]) == 128
class TestFallbackPath:
"""When tokenizer does NOT support ``return_assistant_tokens_mask``."""
def test_fallback_single_turn(self):
from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "World"},
]
out = build_assistant_only_labels(messages, tok)
non_masked = [lab for lab in out["labels"] if lab != IGNORE_INDEX]
# The fallback marks the *delta* tokens of each assistant turn — that
# delta is `<assistant>:World\n` = 17 chars (prefix+content+newline).
# The exact count depends on the renderer, but non-masked must contain
# 'W','o','r','l','d' chars at minimum.
assert len(non_masked) >= 5
# All masked positions must be inside the assistant turn region.
labels = out["labels"]
# Check that the user content is masked. user "Hello" chars are at
# positions 7..12 (after "<user>:"). Verify those are -100.
for pos in range(7, 12):
assert labels[pos] == IGNORE_INDEX
def test_fallback_no_assistant_returns_all_masked(self):
from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [{"role": "user", "content": "no answer"}]
out = build_assistant_only_labels(messages, tok)
assert all(lab == IGNORE_INDEX for lab in out["labels"])
def test_fallback_strict_assistant_only(self):
"""Fallback may include extra prefix tokens; strict mode keeps only
the *content* delta against the next user/system turn."""
from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [
{"role": "user", "content": "Q"},
{"role": "assistant", "content": "A"},
{"role": "user", "content": "Q2"},
]
out = build_assistant_only_labels(messages, tok)
# The user "Q2" must remain masked.
# Render: "<user>:Q\n<assistant>:A\n<user>:Q2\n"
# Last 4 chars are "<user>:Q2\n" prefix+content+newline part of the
# tail — those must all be -100.
labels = out["labels"]
assert labels[-1] == IGNORE_INDEX # newline
assert labels[-2] == IGNORE_INDEX # '2'
assert labels[-3] == IGNORE_INDEX # 'Q'
class TestPerMessageTrainField:
def test_train_field_overrides_default(self):
from soup_cli.data.loss_mask import (
IGNORE_INDEX,
build_per_message_train_labels,
)
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [
{"role": "user", "content": "Q"},
{"role": "assistant", "content": "A1", "train": False},
{"role": "user", "content": "Q2"},
{"role": "assistant", "content": "A2", "train": True},
]
out = build_per_message_train_labels(messages, tok)
# Only A2's content tokens should NOT be IGNORE_INDEX
non_masked = [lab for lab in out["labels"] if lab != IGNORE_INDEX]
# At minimum 'A','2' (both content chars).
assert len(non_masked) >= 2
def test_train_field_default_when_missing(self):
"""Missing 'train' field → role==assistant default."""
from soup_cli.data.loss_mask import (
IGNORE_INDEX,
build_per_message_train_labels,
)
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [
{"role": "user", "content": "Q"},
{"role": "assistant", "content": "A"}, # no 'train' field
]
out = build_per_message_train_labels(messages, tok)
# Default-include assistant when no flag.
non_masked = [lab for lab in out["labels"] if lab != IGNORE_INDEX]
assert len(non_masked) >= 1
class TestEdgeCases:
def test_empty_messages_raises(self):
from soup_cli.data.loss_mask import build_assistant_only_labels
tok = _FakeTokenizer()
with pytest.raises(ValueError, match="empty"):
build_assistant_only_labels([], tok)
def test_max_length_must_be_positive(self):
from soup_cli.data.loss_mask import build_assistant_only_labels
tok = _FakeTokenizer()
messages = [{"role": "user", "content": "x"}]
with pytest.raises(ValueError, match="max_length"):
build_assistant_only_labels(messages, tok, max_length=0)
def test_max_length_rejects_bool(self):
"""`bool` is a subclass of `int` — guard like v0.30.0 Candidate."""
from soup_cli.data.loss_mask import build_assistant_only_labels
tok = _FakeTokenizer()
messages = [{"role": "user", "content": "x"}]
with pytest.raises(ValueError, match="max_length"):
build_assistant_only_labels(messages, tok, max_length=True)
def test_per_message_max_length_truncates(self):
from soup_cli.data.loss_mask import build_per_message_train_labels
tok = _FakeTokenizer(supports_assistant_mask=False)
messages = [
{"role": "user", "content": "x" * 500},
{"role": "assistant", "content": "y" * 500, "train": True},
]
out = build_per_message_train_labels(messages, tok, max_length=32)
assert len(out["input_ids"]) == 32
assert len(out["labels"]) == 32
assert len(out["attention_mask"]) == 32
def test_tokenizer_without_chat_template_raises(self):
"""Hard-fail when tokenizer has no chat_template."""
from soup_cli.data.loss_mask import build_assistant_only_labels
class _NoTemplate:
chat_template = None
def apply_chat_template(self, *args, **kwargs):
raise ValueError("tokenizer has no chat_template")
with pytest.raises(ValueError, match="chat_template"):
build_assistant_only_labels(
[{"role": "user", "content": "x"}], _NoTemplate()
)
# ---------------------------------------------------------------------------
# build_format_row factory (sft.py wiring)
# ---------------------------------------------------------------------------
class TestBuildFormatRow:
@staticmethod
def _row():
return {
"messages": [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hey"},
]
}
def test_default_responses_only_returns_input_ids_labels(self):
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
tok = _FakeTokenizer(supports_assistant_mask=True)
cfg = DataConfig(train="data.jsonl") # default train_on_responses_only=True
fn = build_format_row(tok, cfg, console=None)
out = fn(self._row())
assert "input_ids" in out
assert "labels" in out
assert "attention_mask" in out
def test_per_message_train_field_path(self):
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
tok = _FakeTokenizer(supports_assistant_mask=False)
cfg = DataConfig(
train="data.jsonl",
train_on_responses_only=False,
train_on_messages_with_train_field=True,
)
fn = build_format_row(tok, cfg, console=None)
out = fn(self._row())
assert "labels" in out
def test_legacy_text_path_when_both_false(self):
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
tok = _FakeTokenizer(supports_assistant_mask=True)
cfg = DataConfig(
train="data.jsonl",
train_on_responses_only=False,
train_on_messages_with_train_field=False,
)
fn = build_format_row(tok, cfg)
out = fn(self._row())
assert "text" in out
assert "input_ids" not in out
def test_no_chat_template_calling_format_row_raises(self):
"""v0.36.0 Part C: previous silent fallback now raises ValueError."""
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
class _NoTemplate:
chat_template = None
def apply_chat_template(self, *args, **kwargs): # pragma: no cover
raise AssertionError("must not be called")
cfg = DataConfig(train="data.jsonl") # default responses_only=True
# Factory still returns a callable; calling it on a templateless
# tokenizer raises. (Factory falls back to legacy path which now
# hard-errors instead of building f-string concat.)
fn = build_format_row(_NoTemplate(), cfg, console=None)
with pytest.raises(ValueError, match="chat_template"):
fn(self._row())
def test_max_length_threaded_through(self):
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
tok = _FakeTokenizer(supports_assistant_mask=True)
cfg = DataConfig(train="data.jsonl", max_length=64)
fn = build_format_row(tok, cfg)
long_row = {
"messages": [
{"role": "user", "content": "x" * 1000},
{"role": "assistant", "content": "y" * 1000},
]
}
out = fn(long_row)
assert len(out["input_ids"]) == 64

568
tests/test_batch_probe.py Normal file
View File

@ -0,0 +1,568 @@
"""Tests for OOM-probe auto batch-size (v0.36.0 Part D).
Replaces sft.py's static-formula auto-batch with a real try/halve probe and
a per-machine cache so repeat runs short-circuit. Mirrors LlamaFactory and
Axolotl behaviour.
"""
from __future__ import annotations
import json
import pytest
class _OOMError(Exception):
"""Stand-in for ``torch.cuda.OutOfMemoryError`` in unit tests."""
# ---------------------------------------------------------------------------
# Schema field
# ---------------------------------------------------------------------------
class TestSchemaField:
def test_default_is_auto(self):
from soup_cli.config.schema import TrainingConfig
tcfg = TrainingConfig()
assert tcfg.auto_batch_size_strategy == "auto"
def test_accepts_static(self):
from soup_cli.config.schema import TrainingConfig
tcfg = TrainingConfig(auto_batch_size_strategy="static")
assert tcfg.auto_batch_size_strategy == "static"
def test_accepts_probe(self):
from soup_cli.config.schema import TrainingConfig
tcfg = TrainingConfig(auto_batch_size_strategy="probe")
assert tcfg.auto_batch_size_strategy == "probe"
def test_rejects_unknown_value(self):
from soup_cli.config.schema import TrainingConfig
with pytest.raises(ValueError):
TrainingConfig(auto_batch_size_strategy="random")
# ---------------------------------------------------------------------------
# Pure binary search
# ---------------------------------------------------------------------------
class TestProbeLoop:
def test_converges_when_capacity_below_start(self):
"""Capacity 3, start 4 → halves to 2 (largest power of two that
fits). Doubling probe(4) re-OOMs, so we stay at 2. Power-of-two
granularity is a deliberate choice: we trade exactness for fewer
probe steps (each step is a real GPU forward+backward)."""
from soup_cli.utils.batch_probe import probe_batch_size
capacity = 3
def probe(b: int) -> bool:
if b > capacity:
raise _OOMError("simulated")
return True
out = probe_batch_size(
probe,
start=4,
ceiling=16,
oom_exceptions=(_OOMError,),
)
assert out == 2
def test_converges_when_capacity_above_start(self):
"""Start 2, capacity 8 → doubles 2→4→8→16(OOM), back off → 8."""
from soup_cli.utils.batch_probe import probe_batch_size
capacity = 8
def probe(b: int) -> bool:
if b > capacity:
raise _OOMError("simulated")
return True
out = probe_batch_size(
probe,
start=2,
ceiling=64,
oom_exceptions=(_OOMError,),
)
assert out == capacity
def test_ceiling_caps_at_4x_static(self):
"""Capacity 1000, ceiling 8 → returns 8 (never tries higher)."""
from soup_cli.utils.batch_probe import probe_batch_size
def probe(b: int) -> bool:
return True # never OOMs
out = probe_batch_size(
probe,
start=2,
ceiling=8,
oom_exceptions=(_OOMError,),
)
assert out == 8
def test_starts_oom_halves_to_one(self):
"""Even start=1 OOMs → returns 1 (never go below 1)."""
from soup_cli.utils.batch_probe import probe_batch_size
def probe(b: int) -> bool:
raise _OOMError("starved")
with pytest.raises(RuntimeError, match="batch_size=1"):
probe_batch_size(
probe,
start=2,
ceiling=16,
oom_exceptions=(_OOMError,),
)
def test_max_doublings_capped(self):
"""Search must not run forever — cap at 8 doublings."""
from soup_cli.utils.batch_probe import probe_batch_size
calls: list[int] = []
def probe(b: int) -> bool:
calls.append(b)
return True
probe_batch_size(
probe,
start=1,
ceiling=10**6,
oom_exceptions=(_OOMError,),
max_doublings=8,
)
# At most 8 successful doublings + initial = 9 successful probes.
assert len(calls) <= 12
def test_rejects_invalid_start(self):
from soup_cli.utils.batch_probe import probe_batch_size
with pytest.raises(ValueError, match="start"):
probe_batch_size(
lambda b: True,
start=0,
ceiling=8,
oom_exceptions=(_OOMError,),
)
def test_rejects_invalid_ceiling(self):
from soup_cli.utils.batch_probe import probe_batch_size
with pytest.raises(ValueError, match="ceiling"):
probe_batch_size(
lambda b: True,
start=4,
ceiling=2, # < start
oom_exceptions=(_OOMError,),
)
def test_unrelated_exception_propagates(self):
"""A non-OOM exception must propagate, not be swallowed as OOM."""
from soup_cli.utils.batch_probe import probe_batch_size
def probe(b: int) -> bool:
raise RuntimeError("model bug")
with pytest.raises(RuntimeError, match="model bug"):
probe_batch_size(
probe,
start=2,
ceiling=16,
oom_exceptions=(_OOMError,),
)
# ---------------------------------------------------------------------------
# Cache layer
# ---------------------------------------------------------------------------
class TestCache:
def test_key_normalizes(self):
from soup_cli.utils.batch_probe import make_cache_key
a = make_cache_key(
base="meta-llama/Llama-3.2-1B",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="NVIDIA A100-SXM4-80GB",
gpu_memory_gb=80,
)
b = make_cache_key(
base="meta-llama/Llama-3.2-1B",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="NVIDIA A100-SXM4-80GB",
gpu_memory_gb=80,
)
assert a == b
def test_key_differs_on_quantization(self):
from soup_cli.utils.batch_probe import make_cache_key
a = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
b = make_cache_key("m", 2048, "8bit", 64, "gpu", 80)
assert a != b
def test_key_rejects_bool_inputs(self):
"""v0.30.0 Candidate convention: bool is a subclass of int — guard."""
from soup_cli.utils.batch_probe import make_cache_key
with pytest.raises(ValueError, match="max_length"):
make_cache_key("m", True, "4bit", 64, "gpu", 80)
with pytest.raises(ValueError, match="lora_r"):
make_cache_key("m", 2048, "4bit", True, "gpu", 80)
with pytest.raises(ValueError, match="gpu_memory_gb"):
make_cache_key("m", 2048, "4bit", 64, "gpu", True)
def test_save_and_load_roundtrip(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import (
load_cache,
make_cache_key,
save_cache_entry,
)
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
save_cache_entry(key, 8)
cache = load_cache()
assert cache.get(key) == 8
def test_load_corrupt_returns_empty(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import load_cache
cache_path = tmp_path / "batch_cache.json"
cache_path.write_text("not json", encoding="utf-8")
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
assert load_cache() == {}
def test_load_missing_returns_empty(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import load_cache
cache_path = tmp_path / "missing.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
assert load_cache() == {}
def test_save_rejects_non_positive_value(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import make_cache_key, save_cache_entry
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
with pytest.raises(ValueError):
save_cache_entry(key, 0)
with pytest.raises(ValueError):
save_cache_entry(key, -1)
def test_save_rejects_bool_value(self, tmp_path, monkeypatch):
"""``bool`` is a subclass of int — guard like v0.30.0 Candidate."""
from soup_cli.utils.batch_probe import make_cache_key, save_cache_entry
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
with pytest.raises(ValueError):
save_cache_entry(key, True)
# ---------------------------------------------------------------------------
# pick_batch_size — main entry point
# ---------------------------------------------------------------------------
class TestPickBatchSize:
def test_static_strategy_returns_static_estimate(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
out = pick_batch_size(
static_estimate=4,
strategy="static",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="cpu",
gpu_memory_gb=0,
probe_fn=None,
)
assert out == 4
def test_cache_hit_short_circuits_probe(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import (
make_cache_key,
pick_batch_size,
save_cache_entry,
)
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
save_cache_entry(key, 16)
called: list[int] = []
def probe(b):
called.append(b)
return True
out = pick_batch_size(
static_estimate=4,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="gpu",
gpu_memory_gb=80,
probe_fn=probe,
)
assert out == 16
assert called == [] # probe was not invoked
def test_probe_strategy_runs_probe_and_caches(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import (
load_cache,
make_cache_key,
pick_batch_size,
)
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
capacity = 8
def probe(b):
if b > capacity:
raise _OOMError("oom")
return True
out = pick_batch_size(
static_estimate=4,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="gpu",
gpu_memory_gb=80,
probe_fn=probe,
oom_exceptions=(_OOMError,),
)
assert out == capacity
# Cache write happened.
cache = load_cache()
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
assert cache[key] == capacity
def test_probe_without_callable_falls_back_to_static(self, tmp_path, monkeypatch):
"""No probe_fn supplied (e.g. CPU run) → use static estimate."""
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
out = pick_batch_size(
static_estimate=4,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="cpu",
gpu_memory_gb=0,
probe_fn=None,
)
assert out == 4
def test_auto_strategy_uses_probe_when_probe_fn_supplied(
self, tmp_path, monkeypatch
):
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
capacity = 4
def probe(b):
if b > capacity:
raise _OOMError("oom")
return True
out = pick_batch_size(
static_estimate=2,
strategy="auto",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="gpu",
gpu_memory_gb=80,
probe_fn=probe,
oom_exceptions=(_OOMError,),
)
assert out == capacity
def test_cache_corruption_does_not_block_probe(self, tmp_path, monkeypatch):
"""Corrupt cache file → silently re-probe; ceiling = static * 4."""
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
cache_path.write_text("garbage", encoding="utf-8")
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
out = pick_batch_size(
static_estimate=4,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="gpu",
gpu_memory_gb=80,
probe_fn=lambda b: True,
oom_exceptions=(_OOMError,),
)
# Probe ran; with no OOMs and ceiling = 4*4 = 16, lands at 16.
assert out == 16
def test_runtime_error_propagates_when_bs1_ooms(self, tmp_path, monkeypatch):
"""All-OOM probe → RuntimeError surfaces to caller."""
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
def always_oom(b):
raise _OOMError("oom")
with pytest.raises(RuntimeError, match="batch_size=1"):
pick_batch_size(
static_estimate=2,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="gpu",
gpu_memory_gb=80,
probe_fn=always_oom,
oom_exceptions=(_OOMError,),
)
def test_explicit_probe_no_probe_fn_emits_warning(
self, tmp_path, monkeypatch
):
"""strategy='probe' with probe_fn=None → console warning fires."""
from io import StringIO
from rich.console import Console
from soup_cli.utils.batch_probe import pick_batch_size
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
buf = StringIO()
console = Console(file=buf, force_terminal=False)
out = pick_batch_size(
static_estimate=4,
strategy="probe",
base="m",
max_length=2048,
quantization="4bit",
lora_r=64,
gpu_name="cpu",
gpu_memory_gb=0,
probe_fn=None,
console=console,
)
assert out == 4
assert "probe_fn" in buf.getvalue() or "static" in buf.getvalue()
# ---------------------------------------------------------------------------
# Cache-path containment (security review fix)
# ---------------------------------------------------------------------------
class TestCachePathContainment:
def test_out_of_bounds_override_falls_back_to_default(
self, tmp_path, monkeypatch
):
"""Env var pointing outside home/cwd/tmp → ignored, default used."""
import os
from soup_cli.utils.batch_probe import _cache_path
# Use a sibling-of-temp path that is guaranteed outside any anchor —
# an absolute root we cannot write to is equally fine, since the
# function only resolves+rejects, no I/O.
if os.name == "nt":
evil = "C:\\evil-bound\\batch.json"
else:
evil = "/etc/cron.d/soup_evil"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", evil)
path = _cache_path()
# Fall-through path — must NOT be the evil override.
assert os.path.realpath(path) != os.path.realpath(evil)
assert path.endswith("batch_cache.json")
def test_in_bounds_override_honoured(self, tmp_path, monkeypatch):
import os
from soup_cli.utils.batch_probe import _cache_path
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
# tmp_path is under tempfile.gettempdir() — allowed. Compare via
# realpath to normalise across short-name / forward-slash forms.
assert os.path.realpath(_cache_path()) == os.path.realpath(str(cache_path))
# ---------------------------------------------------------------------------
# Cache file integrity guard
# ---------------------------------------------------------------------------
class TestCacheFileShape:
def test_cache_is_dict_of_str_int(self, tmp_path, monkeypatch):
from soup_cli.utils.batch_probe import make_cache_key, save_cache_entry
cache_path = tmp_path / "batch_cache.json"
monkeypatch.setenv("SOUP_BATCH_CACHE_PATH", str(cache_path))
key = make_cache_key("m", 2048, "4bit", 64, "gpu", 80)
save_cache_entry(key, 8)
with open(cache_path, encoding="utf-8") as f:
data = json.load(f)
assert isinstance(data, dict)
for k, v in data.items():
assert isinstance(k, str)
assert isinstance(v, int)
assert v > 0

276
tests/test_chat_template.py Normal file
View File

@ -0,0 +1,276 @@
"""Tests for chat-template hardening (v0.36.0 Part C).
Replaces sft.py's silent ``f"{role}: {content}"`` fallback (which produced
garbage training data on tokenizers without ``chat_template``) with a hard
error and an explicit ``DataConfig.chat_template`` field for overrides.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Schema field
# ---------------------------------------------------------------------------
class TestSchemaField:
def test_chat_template_default_none(self):
from soup_cli.config.schema import DataConfig
cfg = DataConfig(train="data.jsonl")
assert cfg.chat_template is None
def test_chat_template_accepts_registered_name(self):
from soup_cli.config.schema import DataConfig
cfg = DataConfig(train="data.jsonl", chat_template="chatml")
assert cfg.chat_template == "chatml"
def test_chat_template_accepts_jinja_string(self):
from soup_cli.config.schema import DataConfig
jinja = "{% for m in messages %}{{ m.role }}: {{ m.content }}{% endfor %}"
cfg = DataConfig(train="data.jsonl", chat_template=jinja)
assert cfg.chat_template == jinja
def test_chat_template_rejects_null_byte(self):
from soup_cli.config.schema import DataConfig
with pytest.raises(ValueError):
DataConfig(train="data.jsonl", chat_template="bad\x00template")
def test_chat_template_rejects_oversize(self):
from soup_cli.config.schema import DataConfig
# Cap at 64KB to prevent template-injection DoS payloads.
with pytest.raises(ValueError):
DataConfig(train="data.jsonl", chat_template="x" * 100_000)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestRegistry:
def test_lists_known_templates(self):
from soup_cli.data.chat_templates import list_template_names
names = list_template_names()
# At minimum: the 7 declared in v0.36.0 Part C.
for required in (
"chatml", "llama3", "qwen2.5", "gemma3", "phi4", "deepseek-r1", "mistral",
):
assert required in names
def test_chatml_is_jinja_string(self):
from soup_cli.data.chat_templates import get_template
tmpl = get_template("chatml")
assert isinstance(tmpl, str)
assert "{%" in tmpl
# ChatML signature markers.
assert "<|im_start|>" in tmpl
assert "<|im_end|>" in tmpl
def test_unknown_name_raises(self):
from soup_cli.data.chat_templates import get_template
with pytest.raises(KeyError, match="not registered"):
get_template("not-a-real-template")
def test_resolve_returns_jinja_for_known_name(self):
from soup_cli.data.chat_templates import resolve_chat_template
out = resolve_chat_template("chatml")
assert "<|im_start|>" in out
def test_resolve_returns_passthrough_for_jinja(self):
from soup_cli.data.chat_templates import resolve_chat_template
jinja = "{% for m in messages %}<x>{{ m.content }}</x>{% endfor %}"
out = resolve_chat_template(jinja)
assert out == jinja
def test_resolve_none_returns_none(self):
from soup_cli.data.chat_templates import resolve_chat_template
assert resolve_chat_template(None) is None
def test_resolve_empty_returns_none(self):
from soup_cli.data.chat_templates import resolve_chat_template
assert resolve_chat_template("") is None
def test_resolve_unknown_name_raises(self):
"""Public surface: bad name through resolve_chat_template."""
from soup_cli.data.chat_templates import resolve_chat_template
with pytest.raises(KeyError, match="not registered"):
resolve_chat_template("not-a-real-template")
# ---------------------------------------------------------------------------
# Schema rejects Jinja directives that touch the filesystem
# ---------------------------------------------------------------------------
class TestJinjaDirectiveBlocking:
@pytest.mark.parametrize(
"bad",
[
"{% include 'config.yaml' %}",
"{%- include 'config.yaml' %}",
"{% import 'os' as os %}",
"{% from 'os' import system %}",
"{% macro evil() %}{% endmacro %}",
"{% extends 'base.j2' %}",
],
)
def test_schema_rejects_filesystem_directives(self, bad):
from soup_cli.config.schema import DataConfig
with pytest.raises(ValueError, match="directive"):
DataConfig(train="data.jsonl", chat_template=bad)
def test_schema_accepts_for_loop(self):
"""Standard control flow must still work."""
from soup_cli.config.schema import DataConfig
ok = "{% for m in messages %}{{ m.content }}{% endfor %}"
cfg = DataConfig(train="data.jsonl", chat_template=ok)
assert cfg.chat_template == ok
def test_schema_accepts_if_else(self):
from soup_cli.config.schema import DataConfig
ok = "{% if true %}x{% else %}y{% endif %}"
cfg = DataConfig(train="data.jsonl", chat_template=ok)
assert cfg.chat_template == ok
def test_schema_empty_string_normalised_to_none(self):
from soup_cli.config.schema import DataConfig
cfg = DataConfig(train="data.jsonl", chat_template="")
assert cfg.chat_template is None
# ---------------------------------------------------------------------------
# apply_chat_template_override
# ---------------------------------------------------------------------------
class TestApplyOverride:
def test_sets_tokenizer_chat_template(self):
from soup_cli.data.chat_templates import apply_chat_template_override
class _T:
chat_template = None
tok = _T()
apply_chat_template_override(tok, "chatml")
assert tok.chat_template is not None
assert "<|im_start|>" in tok.chat_template
def test_none_leaves_tokenizer_alone(self):
from soup_cli.data.chat_templates import apply_chat_template_override
class _T:
chat_template = "existing"
tok = _T()
apply_chat_template_override(tok, None)
assert tok.chat_template == "existing"
def test_empty_leaves_tokenizer_alone(self):
from soup_cli.data.chat_templates import apply_chat_template_override
class _T:
chat_template = "existing"
tok = _T()
apply_chat_template_override(tok, "")
assert tok.chat_template == "existing"
def test_override_emits_save_pretrained_warning(self):
"""v0.36.0 review fix: warn when overriding so users know push will
persist the new template into tokenizer_config.json."""
from io import StringIO
from rich.console import Console
from soup_cli.data.chat_templates import apply_chat_template_override
class _T:
chat_template = None
buf = StringIO()
console = Console(file=buf, force_terminal=False)
applied = apply_chat_template_override(_T(), "chatml", console=console)
assert applied is True
assert "save_pretrained" in buf.getvalue() or "soup push" in buf.getvalue()
def test_override_returns_false_when_noop(self):
from soup_cli.data.chat_templates import apply_chat_template_override
class _T:
chat_template = "existing"
applied = apply_chat_template_override(_T(), None)
assert applied is False
# ---------------------------------------------------------------------------
# Hard error in sft_format when no chat_template AND no override
# ---------------------------------------------------------------------------
class TestHardError:
def test_no_template_no_override_raises(self):
"""The legacy `f"{role}: {content}"` silent fallback is now an error."""
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
class _NoTemplate:
chat_template = None
def apply_chat_template(self, *args, **kwargs):
raise AssertionError("must not be reached")
cfg = DataConfig(
train="data.jsonl",
train_on_responses_only=False, # legacy path
train_on_messages_with_train_field=False,
chat_template=None,
)
fn = build_format_row(_NoTemplate(), cfg, console=None)
with pytest.raises(ValueError, match="chat_template"):
fn({"messages": [{"role": "user", "content": "hi"}]})
def test_override_applies_to_legacy_path(self):
"""When user passes chat_template override, build_format_row works."""
from soup_cli.config.schema import DataConfig
from soup_cli.data.sft_format import build_format_row
class _T:
chat_template = None
applied = []
def apply_chat_template(
self, messages, tokenize=False, add_generation_prompt=False, **kwargs
):
self.applied.append(messages)
return "RENDERED"
tok = _T()
cfg = DataConfig(
train="data.jsonl",
train_on_responses_only=False,
train_on_messages_with_train_field=False,
chat_template="chatml",
)
fn = build_format_row(tok, cfg, console=None)
out = fn({"messages": [{"role": "user", "content": "hi"}]})
assert out["text"] == "RENDERED"
assert tok.chat_template is not None # override was applied

View File

@ -0,0 +1,266 @@
"""Tests for ``--trust-remote-code`` opt-in (v0.36.0 Part B).
Replaces the previous unconditional ``trust_remote_code=True`` smell across
sft.py / chat.py / serve.py with an explicit, auditable opt-in flag plus a
trusted-org allowlist that suppresses noise on first-party models.
"""
from __future__ import annotations
from io import StringIO
import pytest
from rich.console import Console
# ---------------------------------------------------------------------------
# Allowlist
# ---------------------------------------------------------------------------
class TestKnownSafePrefixes:
def test_meta_llama_is_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert is_known_safe("meta-llama/Llama-3.2-1B")
def test_qwen_is_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert is_known_safe("Qwen/Qwen2.5-7B")
def test_mistral_is_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert is_known_safe("mistralai/Mistral-7B-Instruct-v0.3")
def test_random_org_not_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert not is_known_safe("randomuser/SomeModel")
def test_local_path_not_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert not is_known_safe("./local-checkpoint")
def test_partial_prefix_does_not_match(self):
"""`meta-llama-evil/...` must NOT match the `meta-llama/` prefix."""
from soup_cli.utils.trust_remote import is_known_safe
assert not is_known_safe("meta-llama-evil/SomeModel")
def test_empty_string_not_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert not is_known_safe("")
def test_non_string_not_safe(self):
from soup_cli.utils.trust_remote import is_known_safe
assert not is_known_safe(None)
assert not is_known_safe(123)
# ---------------------------------------------------------------------------
# resolve_trust_remote_code — main entry
# ---------------------------------------------------------------------------
class TestResolve:
def test_default_off_for_safe_prefix_passes_silently(self):
"""Trusted org + flag off → returns False, no warning."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
buf = StringIO()
console = Console(file=buf, force_terminal=False)
out = resolve_trust_remote_code(
"meta-llama/Llama-3.2-1B",
requested=False,
console=console,
requires_remote_code=False,
)
assert out is False
assert buf.getvalue() == ""
def test_flag_enabled_warns_once(self):
"""User opted in → return True + warning panel."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
buf = StringIO()
console = Console(file=buf, force_terminal=False)
out = resolve_trust_remote_code(
"shady-org/SomeModel",
requested=True,
console=console,
requires_remote_code=True,
)
assert out is True
output = buf.getvalue()
assert "trust_remote_code" in output.lower() or "remote code" in output.lower()
assert "shady-org/SomeModel" in output
def test_flag_enabled_safe_prefix_suppresses_warning(self):
"""Trusted org doesn't ship custom code — suppress warning even when flag set."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
buf = StringIO()
console = Console(file=buf, force_terminal=False)
out = resolve_trust_remote_code(
"meta-llama/Llama-3.2-1B",
requested=True,
console=console,
requires_remote_code=False,
)
assert out is True
# No noisy panel when the model is from a trusted prefix.
assert "WARNING" not in buf.getvalue().upper()
def test_default_off_for_unknown_with_remote_code_raises(self):
"""Model needs custom code + flag off → fail fast with actionable error."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
buf = StringIO()
console = Console(file=buf, force_terminal=False)
with pytest.raises(ValueError) as exc_info:
resolve_trust_remote_code(
"shady-org/CustomModel",
requested=False,
console=console,
requires_remote_code=True,
)
msg = str(exc_info.value)
assert "shady-org/CustomModel" in msg
assert "--trust-remote-code" in msg
def test_default_off_for_unknown_without_remote_code_passes(self):
"""Standard model + flag off → returns False, no error."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
buf = StringIO()
console = Console(file=buf, force_terminal=False)
out = resolve_trust_remote_code(
"shady-org/StandardLlama",
requested=False,
console=console,
requires_remote_code=False,
)
assert out is False
def test_console_optional(self):
"""resolve_trust_remote_code must work when console is None."""
from soup_cli.utils.trust_remote import resolve_trust_remote_code
out = resolve_trust_remote_code(
"meta-llama/Llama-3.2-1B",
requested=True,
console=None,
requires_remote_code=False,
)
assert out is True
def test_invalid_model_name_rejected(self):
from soup_cli.utils.trust_remote import resolve_trust_remote_code
with pytest.raises(ValueError, match="model_name"):
resolve_trust_remote_code(
"",
requested=False,
console=None,
requires_remote_code=False,
)
# ---------------------------------------------------------------------------
# model_requires_trust_remote_code (probe HF config for auto_map)
# ---------------------------------------------------------------------------
class TestRequiresProbe:
def test_local_path_no_auto_map_returns_false(self, tmp_path, monkeypatch):
"""Local path with config.json lacking auto_map → False."""
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
config = tmp_path / "config.json"
config.write_text('{"model_type": "llama"}', encoding="utf-8")
assert model_requires_trust_remote_code(str(tmp_path)) is False
def test_local_path_with_auto_map_returns_true(self, tmp_path):
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
config = tmp_path / "config.json"
config.write_text(
'{"model_type": "custom", "auto_map": '
'{"AutoModelForCausalLM": "modeling.Custom"}}',
encoding="utf-8",
)
assert model_requires_trust_remote_code(str(tmp_path)) is True
def test_missing_config_returns_none(self, tmp_path):
"""Missing config.json → None (unknown — caller decides)."""
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
out = model_requires_trust_remote_code(str(tmp_path))
assert out is None
def test_malformed_config_returns_none(self, tmp_path):
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
config = tmp_path / "config.json"
config.write_text("{this is not json", encoding="utf-8")
out = model_requires_trust_remote_code(str(tmp_path))
assert out is None
def test_non_dict_root_returns_none(self, tmp_path):
"""Config with non-dict root (e.g. JSON array) → None."""
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
config = tmp_path / "config.json"
config.write_text("[1, 2, 3]", encoding="utf-8")
out = model_requires_trust_remote_code(str(tmp_path))
assert out is None
def test_non_directory_path_returns_none(self):
"""Bare HF repo id (not a local dir) → None (unknown)."""
from soup_cli.utils.trust_remote import model_requires_trust_remote_code
out = model_requires_trust_remote_code("meta-llama/Llama-3.2-1B")
assert out is None
# ---------------------------------------------------------------------------
# CLI flag plumbing
# ---------------------------------------------------------------------------
class TestCLIPlumbing:
"""Smoke check that --trust-remote-code is a registered Typer option."""
def test_train_help_lists_flag(self):
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
result = runner.invoke(app, ["train", "--help"])
assert result.exit_code == 0
assert "--trust-remote-code" in result.output
def test_chat_help_lists_flag(self):
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
result = runner.invoke(app, ["chat", "--help"])
assert result.exit_code == 0
assert "--trust-remote-code" in result.output
def test_serve_help_lists_flag(self):
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
result = runner.invoke(app, ["serve", "--help"])
assert result.exit_code == 0
assert "--trust-remote-code" in result.output