v0.10.1: Fix 6 bugs from manual testing report

- BUG-001: Replace Unicode arrows/dashes with ASCII in all console output
  to fix UnicodeEncodeError on Windows cp1252 (~10 commands affected)
- BUG-002: PPO trainer uses inspect.signature to detect trl parameter names
  (ppo_epochs vs num_ppo_epochs) for trl 0.28.0 compatibility
- BUG-003: Add get_compute_dtype() - uses float32 on CPU, bfloat16/float16
  on CUDA. Fixes dtype mismatch in reward model and all trainers
- BUG-004: Add warning when using quantization on CPU
- BUG-005: Fix dtype -> torch_dtype in diff.py model loading
- BUG-006: Pin wandb<0.18.0 to avoid trl import conflict, add runtime guard

13 new tests (624 total), ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-03-24 12:31:37 +05:00
parent ccccb25817
commit 428c0f09a4
24 changed files with 426 additions and 95 deletions

View File

@ -182,3 +182,4 @@ Test suite lives in `tests/`:
| `test_ui.py` | Web UI command, FastAPI endpoints, static files, config validation |
| `test_vllm_serve.py` | vLLM backend detection, engine creation, serve --backend flag, FastAPI app |
| `test_ppo.py` | PPO config, reward model config, data prep, RLHF template, routing, sweep |
| `test_bugfixes.py` | v0.10.1 bugfixes: Windows Unicode, PPO params, compute dtype, diff dtype, wandb pin |

130
plan.md
View File

@ -2,7 +2,7 @@
**Repo:** https://github.com/MakazhanAlpamys/Soup
**PyPI:** https://pypi.org/project/soup-cli/ (`pip install soup-cli`)
**Version:** v0.10.0 | 611 tests | CI green
**Version:** v0.10.1 | 624 tests | CI green
### How to publish
@ -16,9 +16,7 @@ git push --tags
---
## Completed (v0.1.0 v0.10.0)
All core CLI functionality is shipped:
## Completed (v0.1.0 v0.10.1)
- **CLI:** init, train, chat, push, merge, export, eval, serve, sweep, diff, doctor, quickstart, ui, version
- **Data:** inspect, validate, convert, merge, dedup, stats, generate
@ -28,23 +26,104 @@ All core CLI functionality is shipped:
- **Tracking:** SQLite experiment tracker, runs list/show/compare/delete
- **Export:** GGUF (Ollama/llama.cpp), LoRA merge
- **Web UI:** Dashboard, New Training, Data Explorer, Model Chat (FastAPI + SPA)
- **UX:** friendly errors, --verbose, confirmation prompts, Rich progress bars
- **Community:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, examples/, FUNDING.yml, issue/PR templates, GitHub Discussions
- **Tests:** 611 tests, 40 files, ruff lint, CI on Python 3.9/3.11/3.12
- **UX:** friendly errors, --verbose, Rich progress bars, confirmation prompts
- **Community:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, examples/, FUNDING.yml
- **Tests:** 624 tests, 41 files, ruff lint, CI on Python 3.9/3.11/3.12
- **v0.10.1 bugfixes:** Windows UnicodeEncodeError, PPO trl compat, compute dtype for CPU, diff torch_dtype, wandb version pin
---
## Next
## Roadmap
- [ ] First post on Reddit (r/LocalLLaMA, r/MachineLearning)
- [ ] Community building, content, marketing
### P0 — Critical (v1.0v1.1)
#### Community & Marketing
- [ ] Discord server
- [ ] First Reddit post (r/LocalLLaMA, r/MachineLearning)
- [ ] HuggingFace models showcase ("trained-with-soup" tag)
- [ ] 3-5 YouTube tutorials / blog posts with benchmarks (Soup vs LLaMA-Factory vs Axolotl)
- [ ] Supported models page in README (Llama 4, Gemma 3, Qwen 2.5/3, Phi-4, DeepSeek R1/V3, Mistral, Mixtral)
#### Advanced PEFT methods
- [ ] DoRA (`peft_type: dora`) — improved LoRA with magnitude decomposition
- [ ] GaLore — memory-efficient full-parameter training on consumer GPUs
- [ ] LoRA+ — different learning rates for A and B matrices
#### More alignment methods
- [ ] KTO (Kahneman-Tversky Optimization) — `task: kto`, doesn't need paired data
- [ ] ORPO (Odds Ratio Preference Optimization) — `task: orpo`, no reference model needed
- [ ] SimPO — `task: simpo`, simple preference optimization
- [ ] IPO (Identity Preference Optimization) — `task: ipo`
#### Pre-training / Continued pre-training
- [ ] `task: pretrain` — continued pre-training on raw text
- [ ] Support for plain text / tokenized datasets
#### Cloud GPU providers
- [ ] `soup cloud run --provider runpod --gpu a100 --config soup.yaml`
- [ ] RunPod, Vast.ai, Lambda Labs, Modal integration
- [ ] Auto-setup: upload data → rent GPU → train → return adapter → teardown
- [ ] Cost estimator and budget auto-stop
---
## Future (when there's demand)
### P1 — Important (v1.2v1.3)
- **Cloud Mode (BYOG)** — Modal/RunPod/Vast integration, cost estimator
- **Managed Platform (SaaS)** — app.soup.dev, monetization (when 300+ stars)
#### MoE support
- [ ] Explicit MoE model support (Qwen3 30B-A3B, Mixtral, DeepSeek V3)
- [ ] ScatterMoE LoRA for efficient MoE fine-tuning
- [ ] Documentation and examples for MoE training
#### Long-context training
- [ ] Sequence parallelism via Ring FlashAttention
- [ ] 128k+ context fine-tuning across multiple GPUs
- [ ] Neat packing (contamination-free) for long sequences
#### Embedding models
- [ ] `task: embedding` — fine-tune sentence transformers, BGE, E5
- [ ] `soup init --template embedding`
- [ ] Contrastive loss, triplet loss support
#### Advanced distributed training
- [ ] FSDP2 support alongside DeepSpeed
- [ ] Multi-node training via torchrun / Ray
- [ ] Tensor Parallelism + Context Parallelism combined
#### Performance optimizations
- [ ] Liger Kernel integration (fused operations)
- [ ] FlashAttention-3/4 auto-detection
- [ ] SageAttention support
---
### P2 — Nice to have (v1.4+)
#### Export formats
- [ ] ONNX export (`soup export --format onnx`)
- [ ] TensorRT-LLM export
- [ ] SGLang backend for serving
#### Integrations
- [ ] MLflow tracking (enterprise alternative to W&B)
- [ ] TensorBoard integration
- [ ] `uv` package manager support
#### Serving improvements
- [ ] Speculative decoding (`soup serve --speculative-decoding`)
- [ ] Batch inference mode (`soup infer --input prompts.jsonl --output results.jsonl`)
#### Data improvements
- [ ] Multi-agent synthetic data generation (GraphGen-style)
- [ ] Quality filters for generated data (perplexity, coherence scoring)
- [ ] Local model as data generation provider (not just OpenAI API)
#### Audio modality
- [ ] `modality: audio` — Qwen2-Audio, Whisper fine-tuning
- [ ] Audio dataset formats and inspection
#### Smart suggestions
- [ ] Auto-detect hardware and recommend optimal config
- [ ] "You have 2×H100 — recommend FSDP2 + Unsloth" style hints
---
@ -55,3 +134,28 @@ All core CLI functionality is shipped:
3. **Fail fast, fail loud** — bad data or missing GPU = immediate, clear error
4. **Open source core** — CLI is always free; monetize via managed service
5. **Test-driven** — every feature has tests, written alongside the code
---
## Competitive positioning
| Feature | Soup | LLaMA-Factory | Axolotl | Unsloth |
|---|---|---|---|---|
| One-command training | **Yes** | Partial | No | Notebook |
| Web UI | **Yes** | Yes | No | No |
| Experiment tracking | **Built-in SQLite** | W&B only | W&B only | No |
| Hyperparam sweep | **Yes + early-stop** | No | No | No |
| Data toolkit (7 tools) | **Yes** | Basic | No | No |
| Model diff | **Yes** | No | No | No |
| GGUF export | **Yes** | Yes | No | Yes |
| GRPO + custom rewards | **Yes** | Yes | Yes | Yes |
| Full RLHF pipeline | **Yes** | Yes | Yes | No |
| Cloud GPU | No | SageMaker | RunPod templates | No |
| MoE training | No | Yes | Yes | **12x faster** |
| DoRA/GaLore | No | Yes | Yes | Partial |
| KTO/ORPO/SimPO | No | Yes | Yes | No |
| Pre-training | No | Yes | Yes | Yes |
| 100+ model day-0 | No | **Yes** | Partial | Yes |
**Soup's edge:** best UX, most integrated toolkit, lowest barrier to entry.
**Gap to close:** cloud, advanced PEFT, more alignment methods, model breadth.

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.10.0"
version = "0.10.1"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "MIT"
@ -40,7 +40,7 @@ dependencies = [
[project.optional-dependencies]
eval = ["lm-eval>=0.4.0"]
data = ["datasketch>=1.6.0"]
wandb = ["wandb>=0.15.0"]
wandb = ["wandb>=0.15.0,<0.18.0"]
dev = ["pytest>=7.0", "ruff>=0.1.0", "pytest-cov>=4.0", "httpx>=0.24.0"]
ui = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
serve = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]

View File

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

View File

@ -92,9 +92,9 @@ def chat(
)
console.print("[bold green]Model loaded![/] Type your message. Commands:")
console.print(" [dim]/quit[/] exit chat")
console.print(" [dim]/clear[/] reset conversation history")
console.print(" [dim]/system <text>[/] set system prompt")
console.print(" [dim]/quit[/] - exit chat")
console.print(" [dim]/clear[/] - reset conversation history")
console.print(" [dim]/system <text>[/] - set system prompt")
console.print()
# Chat loop

View File

@ -138,7 +138,7 @@ def convert(
console.print("[red]Cannot convert DPO format (preference pairs are not conversations).[/]")
raise typer.Exit(1)
# Convert: source → messages → target
# Convert: source -> messages -> target
converted = []
failed = 0
for row in data:
@ -164,7 +164,7 @@ def convert(
_write_jsonl(out_path, converted)
console.print(
f"[green]Converted {len(converted)} rows:[/] {src_fmt} {to}\n"
f"[green]Converted {len(converted)} rows:[/] {src_fmt} -> {to}\n"
f"Output: [bold]{out_path}[/]"
)
if failed > 0:
@ -295,7 +295,7 @@ def dedup(
_write_jsonl(out_path, unique_data)
console.print(
f"[green]Dedup complete:[/] {len(data)} {len(unique_data)} rows "
f"[green]Dedup complete:[/] {len(data)} -> {len(unique_data)} rows "
f"([red]-{removed}[/] duplicates)\n"
f"Output: [bold]{out_path}[/]"
)

View File

@ -231,7 +231,7 @@ def _load_model(model_path: str, base_model: Optional[str], device: str):
base_model,
trust_remote_code=True,
device_map="auto",
dtype=torch.float16,
torch_dtype=torch.float16,
)
model_obj = PeftModel.from_pretrained(base, model_path)
else:
@ -239,7 +239,7 @@ def _load_model(model_path: str, base_model: Optional[str], device: str):
model_path,
trust_remote_code=True,
device_map="auto",
dtype=torch.float16,
torch_dtype=torch.float16,
)
model_obj.eval()

View File

@ -39,7 +39,7 @@ DEPS = [
def doctor():
"""Check system dependencies, GPU, and compatibility."""
console.print("[bold]Soup Doctor[/] checking your environment...\n")
console.print("[bold]Soup Doctor[/] - checking your environment...\n")
# System info
console.print(
@ -93,7 +93,7 @@ def doctor():
table.add_row(
pkg_name,
"yes" if required else "optional",
"",
"-",
f">={min_ver}",
status,
)

View File

@ -138,7 +138,7 @@ def _display_results(results: dict, benchmarks: list[str]) -> None:
for benchmark in benchmarks:
bench_data = task_results.get(benchmark, {})
if not bench_data:
table.add_row(benchmark, "", "[red]not found[/]")
table.add_row(benchmark, "-", "[red]not found[/]")
continue
# Try common metric names
@ -156,7 +156,7 @@ def _display_results(results: dict, benchmarks: list[str]) -> None:
table.add_row(benchmark, metric_name, f"{val:.4f}")
break
else:
table.add_row(benchmark, "", "[yellow]no numeric result[/]")
table.add_row(benchmark, "-", "[yellow]no numeric result[/]")
console.print(table)

View File

@ -83,7 +83,7 @@ def export(
merge_dir = None
if is_adapter:
console.print("[yellow]LoRA adapter detected merging with base model first...[/]")
console.print("[yellow]LoRA adapter detected - merging with base model first...[/]")
base_model = base or _detect_base_model(adapter_config_path)
if not base_model:
console.print(
@ -152,7 +152,7 @@ def export(
shutil.rmtree(merge_dir, ignore_errors=True)
if not output_path.exists():
console.print("[red]Export failed output file not created.[/]")
console.print("[red]Export failed - output file not created.[/]")
raise typer.Exit(1)
file_size = output_path.stat().st_size

View File

@ -126,7 +126,7 @@ def quickstart(
console.print(f"[green]Created:[/] {config_path}")
if dry_run:
console.print("\n[yellow]Dry run files created, skipping training.[/]")
console.print("\n[yellow]Dry run - files created, skipping training.[/]")
console.print(f"To train: [bold]soup train --config {config_path}[/]")
raise typer.Exit()

View File

@ -57,7 +57,7 @@ def list_runs(
# Format loss
loss_str = ""
if run.get("initial_loss") and run.get("final_loss"):
loss_str = f"{run['initial_loss']:.3f} {run['final_loss']:.3f}"
loss_str = f"{run['initial_loss']:.3f} -> {run['final_loss']:.3f}"
# Format duration
duration_str = ""
@ -117,7 +117,7 @@ def show(
status_str = "[yellow]running[/]"
# Format duration
duration_str = ""
duration_str = "-"
if run.get("duration_secs"):
secs = run["duration_secs"]
hours = int(secs // 3600)
@ -127,19 +127,19 @@ def show(
# Build info panel
info_lines = [
f"Run ID: [bold]{run['run_id']}[/]",
f"Name: {run.get('experiment_name') or ''}",
f"Name: {run.get('experiment_name') or '-'}",
f"Status: {status_str}",
f"Date: {run['created_at'][:19].replace('T', ' ')}",
"",
f"Model: [bold]{run.get('base_model') or ''}[/]",
f"Task: {run.get('task') or ''}",
f"Device: {run.get('device_name') or ''} ({run.get('device') or ''})",
f"GPU Memory: {run.get('gpu_memory') or ''}",
f"Model: [bold]{run.get('base_model') or '-'}[/]",
f"Task: {run.get('task') or '-'}",
f"Device: {run.get('device_name') or '-'} ({run.get('device') or '-'})",
f"GPU Memory: {run.get('gpu_memory') or '-'}",
"",
f"Loss: {_fmt_loss(run)}",
f"Steps: {run.get('total_steps') or ''}",
f"Steps: {run.get('total_steps') or '-'}",
f"Duration: {duration_str}",
f"Output: {run.get('output_dir') or ''}",
f"Output: {run.get('output_dir') or '-'}",
]
console.print(Panel("\n".join(info_lines), title="Run Details"))
@ -197,14 +197,14 @@ def compare(
table.add_column(r2["run_id"][:20], justify="right")
rows = [
("Name", r1.get("experiment_name") or "", r2.get("experiment_name") or ""),
("Model", r1.get("base_model") or "", r2.get("base_model") or ""),
("Task", r1.get("task") or "", r2.get("task") or ""),
("Device", r1.get("device_name") or "", r2.get("device_name") or ""),
("Status", r1.get("status") or "", r2.get("status") or ""),
("Name", r1.get("experiment_name") or "-", r2.get("experiment_name") or "-"),
("Model", r1.get("base_model") or "-", r2.get("base_model") or "-"),
("Task", r1.get("task") or "-", r2.get("task") or "-"),
("Device", r1.get("device_name") or "-", r2.get("device_name") or "-"),
("Status", r1.get("status") or "-", r2.get("status") or "-"),
("Initial Loss", _fmt_float(r1.get("initial_loss")), _fmt_float(r2.get("initial_loss"))),
("Final Loss", _fmt_float(r1.get("final_loss")), _fmt_float(r2.get("final_loss"))),
("Steps", str(r1.get("total_steps") or ""), str(r2.get("total_steps") or "")),
("Steps", str(r1.get("total_steps") or "-"), str(r2.get("total_steps") or "-")),
("Duration", _fmt_duration(r1.get("duration_secs")),
_fmt_duration(r2.get("duration_secs"))),
]
@ -223,19 +223,19 @@ def compare(
training1 = c1.get("training", {})
training2 = c2.get("training", {})
rows.extend([
("Epochs", str(training1.get("epochs", "")), str(training2.get("epochs", ""))),
("Learning Rate", str(training1.get("lr", "")), str(training2.get("lr", ""))),
("Batch Size", str(training1.get("batch_size", "")),
str(training2.get("batch_size", ""))),
("Quantization", str(training1.get("quantization", "")),
str(training2.get("quantization", ""))),
("Epochs", str(training1.get("epochs", "-")), str(training2.get("epochs", "-"))),
("Learning Rate", str(training1.get("lr", "-")), str(training2.get("lr", "-"))),
("Batch Size", str(training1.get("batch_size", "-")),
str(training2.get("batch_size", "-"))),
("Quantization", str(training1.get("quantization", "-")),
str(training2.get("quantization", "-"))),
])
lora1 = training1.get("lora", {})
lora2 = training2.get("lora", {})
rows.extend([
("LoRA r", str(lora1.get("r", "")), str(lora2.get("r", ""))),
("LoRA alpha", str(lora1.get("alpha", "")), str(lora2.get("alpha", ""))),
("LoRA r", str(lora1.get("r", "-")), str(lora2.get("r", "-"))),
("LoRA alpha", str(lora1.get("alpha", "-")), str(lora2.get("alpha", "-"))),
])
for label, val1, val2 in rows:
@ -272,25 +272,25 @@ def delete(
def _fmt_loss(run: dict) -> str:
"""Format loss as 'initial final'."""
"""Format loss as 'initial -> final'."""
init = run.get("initial_loss")
final = run.get("final_loss")
if init is not None and final is not None:
return f"{init:.4f} {final:.4f}"
return ""
return f"{init:.4f} -> {final:.4f}"
return "-"
def _fmt_float(val: Optional[float]) -> str:
"""Format a float or return ''."""
if val is not None:
return f"{val:.4f}"
return ""
return "-"
def _fmt_duration(secs: Optional[float]) -> str:
"""Format duration in seconds to human-readable string."""
if secs is None:
return ""
return "-"
if secs >= 3600:
return f"{secs / 3600:.1f}h"
if secs >= 60:

View File

@ -104,7 +104,7 @@ def sweep(
console.print(param_table)
if dry_run:
console.print("[yellow]Dry run no training will be executed.[/]")
console.print("[yellow]Dry run - no training will be executed.[/]")
raise typer.Exit()
if not yes:
@ -402,13 +402,13 @@ def _display_summary(results: list[dict], sweep_params: dict[str, list]):
for idx, res in enumerate(sorted_results):
status_style = "green" if res["status"] == "completed" else "red"
param_vals = [str(res["params"].get(k, "")) for k in sweep_params]
loss_str = f"{res['final_loss']:.4f}" if res["final_loss"] else ""
loss_str = f"{res['final_loss']:.4f}" if res["final_loss"] else "-"
best_marker = " [bold yellow]*[/]" if idx == 0 and res["status"] == "completed" else ""
table.add_row(
res["name"],
*param_vals,
f"{loss_str}{best_marker}",
res.get("duration", ""),
res.get("duration", "-"),
f"[{status_style}]{res['status']}[/]",
)

View File

@ -86,7 +86,13 @@ def train(
except ImportError:
console.print(
"[red]wandb not installed.[/]\n"
"Run: [bold]pip install wandb[/]"
"Run: [bold]pip install 'soup-cli[wandb]'[/]"
)
raise typer.Exit(1)
except Exception as wandb_err:
console.print(
f"[red]wandb import error:[/] {wandb_err}\n"
"Try: [bold]pip install 'wandb>=0.15.0,<0.18.0'[/]"
)
raise typer.Exit(1)
@ -101,6 +107,13 @@ def train(
device, device_name = detect_device()
gpu_info = get_gpu_info()
# Warn about quantization on CPU
if device == "cpu" and cfg.training.quantization in ("4bit", "8bit"):
console.print(
f"[yellow]Warning: {cfg.training.quantization} quantization on CPU "
"may cause errors. Consider using quantization: none for CPU.[/]"
)
backend_label = cfg.backend
if cfg.backend == "unsloth":
backend_label = "unsloth [green](fast mode)[/]"
@ -150,7 +163,7 @@ def train(
raise typer.Exit()
if dry_run:
console.print("[yellow]Dry run validating data...[/]")
console.print("[yellow]Dry run - validating data...[/]")
dataset = load_dataset(cfg.data)
console.print(f"[green]Data OK:[/] {len(dataset['train'])} train samples")
if "val" in dataset:
@ -236,7 +249,7 @@ def train(
# Report
console.print(
Panel(
f"Loss: [bold]{result['initial_loss']:.4f} {result['final_loss']:.4f}[/]\n"
f"Loss: [bold]{result['initial_loss']:.4f} -> {result['final_loss']:.4f}[/]\n"
f"Duration: [bold]{result['duration']}[/]\n"
f"Output: [bold]{result['output_dir']}[/]\n"
f"Run ID: [bold]{run_id}[/]\n\n"

View File

@ -46,10 +46,10 @@ def ui(
Panel(
f"URL: [bold]{url}[/]\n\n"
f"Pages:\n"
f" [bold]Dashboard[/] View experiments, loss charts, system info\n"
f" [bold]New Training[/] Create config from templates, start training\n"
f" [bold]Data Explorer[/] Browse and inspect datasets\n"
f" [bold]Model Chat[/] Chat with a running inference server\n\n"
f" [bold]Dashboard[/] - View experiments, loss charts, system info\n"
f" [bold]New Training[/] - Create config from templates, start training\n"
f" [bold]Data Explorer[/] - Browse and inspect datasets\n"
f" [bold]Model Chat[/] - Chat with a running inference server\n\n"
f"Press [bold]Ctrl+C[/] to stop.",
title="[bold green]Soup Web UI[/]",
)

View File

@ -24,7 +24,7 @@ def load_config(path: Path) -> SoupConfig:
except ValidationError as e:
console.print("[red bold]Config validation error:[/]\n")
for err in e.errors():
loc = " ".join(str(part) for part in err["loc"])
loc = " -> ".join(str(part) for part in err["loc"])
console.print(f" [red]{loc}:[/] {err['msg']}")
raise SystemExit(1)
@ -46,6 +46,6 @@ def load_config_from_string(yaml_str: str) -> SoupConfig:
except ValidationError as exc:
errors = []
for err in exc.errors():
loc = " ".join(str(part) for part in err["loc"])
loc = " -> ".join(str(part) for part in err["loc"])
errors.append(f"{loc}: {err['msg']}")
raise ValueError("; ".join(errors))

View File

@ -29,7 +29,7 @@ FORMAT_SIGNATURES = {
def detect_format(data: list[dict]) -> str:
"""Auto-detect dataset format from first few rows."""
if not data:
raise ValueError("Empty dataset cannot detect format")
raise ValueError("Empty dataset - cannot detect format")
sample = data[0]
keys = set(sample.keys())

View File

@ -150,12 +150,12 @@ class DPOTrainerWrapper:
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":

View File

@ -159,12 +159,12 @@ class GRPOTrainerWrapper:
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":

View File

@ -108,17 +108,40 @@ class PPOTrainerWrapper:
output_dir.mkdir(parents=True, exist_ok=True)
# --- PPO config ---
ppo_config = PPOConfig(
output_dir=str(output_dir),
per_device_train_batch_size=batch_size,
gradient_accumulation_steps=tcfg.gradient_accumulation_steps,
learning_rate=tcfg.lr,
ppo_epochs=tcfg.ppo_epochs,
cliprange=tcfg.ppo_clip_ratio,
init_kl_coef=tcfg.ppo_kl_penalty,
log_with=self.report_to if self.report_to != "none" else None,
optimize_cuda_cache=self.device == "cuda",
)
# Build kwargs, handling trl version differences
ppo_kwargs = {
"output_dir": str(output_dir),
"per_device_train_batch_size": batch_size,
"gradient_accumulation_steps": tcfg.gradient_accumulation_steps,
"learning_rate": tcfg.lr,
}
# trl renamed ppo_epochs -> num_ppo_epochs in newer versions
import inspect
ppo_params = inspect.signature(PPOConfig).parameters
if "num_ppo_epochs" in ppo_params:
ppo_kwargs["num_ppo_epochs"] = tcfg.ppo_epochs
elif "ppo_epochs" in ppo_params:
ppo_kwargs["ppo_epochs"] = tcfg.ppo_epochs
if "cliprange" in ppo_params:
ppo_kwargs["cliprange"] = tcfg.ppo_clip_ratio
if "init_kl_coef" in ppo_params:
ppo_kwargs["init_kl_coef"] = tcfg.ppo_kl_penalty
# Optional params that may not exist in all trl versions
if "log_with" in ppo_params:
ppo_kwargs["log_with"] = (
self.report_to if self.report_to != "none" else None
)
elif "report_to" in ppo_params:
ppo_kwargs["report_to"] = self.report_to
if "optimize_cuda_cache" in ppo_params:
ppo_kwargs["optimize_cuda_cache"] = self.device == "cuda"
ppo_config = PPOConfig(**ppo_kwargs)
# --- Build reward functions list for PPOTrainer ---
reward_funcs = []
@ -177,12 +200,12 @@ class PPOTrainerWrapper:
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":

View File

@ -157,12 +157,12 @@ class RewardModelTrainerWrapper:
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":

View File

@ -162,12 +162,12 @@ class SFTTrainerWrapper:
# Quantization
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
@ -233,12 +233,12 @@ class SFTTrainerWrapper:
# Quantization
bnb_config = None
if tcfg.quantization == "4bit":
import torch
from soup_cli.utils.gpu import get_compute_dtype
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_compute_dtype=get_compute_dtype(),
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":

View File

@ -113,3 +113,18 @@ def model_size_from_name(model_name: str) -> float:
return size
return 7.0 # default guess
def get_compute_dtype():
"""Return the best compute dtype for the current device.
Uses bfloat16 on CUDA GPUs that support it, float16 otherwise.
On CPU, uses float32 to avoid dtype mismatch errors.
"""
import torch
if torch.cuda.is_available():
if torch.cuda.is_bf16_supported():
return torch.bfloat16
return torch.float16
return torch.float32

175
tests/test_bugfixes.py Normal file
View File

@ -0,0 +1,175 @@
"""Tests for v0.10.1 bug fixes - Windows Unicode, PPO params, dtype, diff."""
from pathlib import Path
from unittest.mock import patch
import pytest
from soup_cli.config.schema import SoupConfig
# --- BUG-001: Windows UnicodeEncodeError (no Unicode arrows/dashes in output) ---
class TestNoUnicodeInOutput:
"""Verify user-facing output uses only ASCII-safe characters."""
def test_config_loader_error_uses_ascii_arrow(self):
"""Config validation errors should use -> not Unicode arrow."""
from soup_cli.config.loader import load_config_from_string
with pytest.raises(ValueError) as exc_info:
load_config_from_string("base: x\ntask: invalid_task\n")
# Error message should use -> not the Unicode arrow
msg = str(exc_info.value)
assert "\u2192" not in msg # no Unicode right arrow
def test_loss_format_uses_ascii(self):
"""Loss formatting in runs should use -> not Unicode arrow."""
from soup_cli.commands.runs import _fmt_loss
run = {"initial_loss": 1.5, "final_loss": 0.5}
result = _fmt_loss(run)
assert "->" in result
assert "\u2192" not in result # no Unicode right arrow
def test_loss_format_missing_returns_ascii(self):
"""Missing loss should return ASCII dash, not em dash."""
from soup_cli.commands.runs import _fmt_loss
result = _fmt_loss({})
assert result == "-"
assert "\u2014" not in result # no em dash
def test_fmt_float_missing_returns_ascii(self):
"""Missing float should return ASCII dash."""
from soup_cli.commands.runs import _fmt_float
result = _fmt_float(None)
assert result == "-"
assert "\u2014" not in result
def test_fmt_duration_missing_returns_ascii(self):
"""Missing duration should return ASCII dash."""
from soup_cli.commands.runs import _fmt_duration
result = _fmt_duration(None)
assert result == "-"
assert "\u2014" not in result
def test_formats_empty_dataset_error_ascii(self):
"""Empty dataset error should use ASCII dash."""
from soup_cli.data.formats import detect_format
with pytest.raises(ValueError, match="Empty dataset"):
detect_format([])
# --- BUG-002: PPO ppo_epochs parameter compatibility ---
class TestPPOParamCompat:
"""Test PPO trainer handles trl version differences."""
def test_ppo_config_uses_inspect(self):
"""PPO setup should use inspect to detect valid parameter names."""
from soup_cli.trainer.ppo import PPOTrainerWrapper
cfg = SoupConfig(
base="test-model",
task="ppo",
data={"train": "./data.jsonl"},
training={
"ppo_epochs": 3,
"ppo_clip_ratio": 0.15,
"ppo_kl_penalty": 0.03,
},
)
wrapper = PPOTrainerWrapper(cfg, device="cpu")
assert wrapper.config.training.ppo_epochs == 3
assert wrapper.config.training.ppo_clip_ratio == pytest.approx(0.15)
assert wrapper.config.training.ppo_kl_penalty == pytest.approx(0.03)
# --- BUG-003: Reward Model dtype mismatch ---
class TestComputeDtype:
"""Test get_compute_dtype returns correct dtype for device."""
def test_cpu_returns_float32(self):
"""CPU should use float32, not bfloat16."""
import torch
from soup_cli.utils.gpu import get_compute_dtype
with patch("torch.cuda.is_available", return_value=False):
dtype = get_compute_dtype()
assert dtype == torch.float32
def test_cuda_with_bf16_returns_bfloat16(self):
"""CUDA with bf16 support should use bfloat16."""
import torch
from soup_cli.utils.gpu import get_compute_dtype
with patch("torch.cuda.is_available", return_value=True), \
patch("torch.cuda.is_bf16_supported", return_value=True):
dtype = get_compute_dtype()
assert dtype == torch.bfloat16
def test_cuda_without_bf16_returns_float16(self):
"""CUDA without bf16 support should fall back to float16."""
import torch
from soup_cli.utils.gpu import get_compute_dtype
with patch("torch.cuda.is_available", return_value=True), \
patch("torch.cuda.is_bf16_supported", return_value=False):
dtype = get_compute_dtype()
assert dtype == torch.float16
# --- BUG-005: diff dtype -> torch_dtype ---
class TestDiffModelLoading:
"""Test diff command uses correct parameter names."""
def test_load_model_uses_torch_dtype(self):
"""_load_model should pass torch_dtype, not dtype."""
import inspect
from soup_cli.commands.diff import _load_model
source = inspect.getsource(_load_model)
assert "torch_dtype=" in source
assert "dtype=" not in source or "torch_dtype=" in source
# --- BUG-006: wandb version pin ---
class TestWandbVersionPin:
"""Test wandb dependency is version-pinned."""
def test_wandb_upper_bound_in_pyproject(self):
"""pyproject.toml should pin wandb below 0.18.0."""
pyproject = Path(__file__).parent.parent / "pyproject.toml"
content = pyproject.read_text(encoding="utf-8")
assert "<0.18.0" in content or "< 0.18.0" in content
# --- BUG-004: CPU quantization warning ---
class TestCPUQuantWarning:
"""Test that CPU + quantization produces a warning."""
def test_train_source_has_cpu_quant_warning(self):
"""train.py should warn about quantization on CPU."""
import inspect
from soup_cli.commands import train
source = inspect.getsource(train)
assert "quantization on CPU" in source