From 42490cfbc445aec4efbe1f7dd6650664d9036679 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Wed, 10 Dec 2025 10:45:39 -0500 Subject: [PATCH 01/17] train CLI --- cli.py | 326 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 cli.py diff --git a/cli.py b/cli.py new file mode 100644 index 0000000000..972e1884ad --- /dev/null +++ b/cli.py @@ -0,0 +1,326 @@ +import logging +import sys +import time +from pathlib import Path +from typing import Optional, List, TYPE_CHECKING + +import typer + +if TYPE_CHECKING: + # Only import for type hints to avoid triggering heavy backend initialization on CLI --help + from backend.trainer import TrainingProgress + +app = typer.Typer( + help="Command-line interface for Unsloth training, chat, and export.", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +def configure_logging(verbose: bool): + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", + datefmt="%H:%M:%S", + ) + + +def _print_progress(progress): + parts = [] + if progress.step: + if progress.total_steps: + parts.append(f"step {progress.step}/{progress.total_steps}") + else: + parts.append(f"step {progress.step}") + if progress.epoch: + parts.append(f"epoch {progress.epoch}") + if progress.loss: + parts.append(f"loss {progress.loss:.4f}") + if progress.learning_rate: + parts.append(f"lr {progress.learning_rate:.2e}") + status = progress.status_message or "" + if not parts and status: + line = status + else: + line = " | ".join(parts) + if status: + line = f"{line} | {status}" + if line: + typer.echo(line) + + +@app.command() +def train( + model: str = typer.Argument(..., help="HF model id or local path."), + dataset: Optional[str] = typer.Option( + None, + "--dataset", + "-d", + help="HF dataset to train on (e.g. 'tatsu-lab/alpaca').", + ), + local_dataset: Optional[List[str]] = typer.Option( + None, + "--local-dataset", + help="Filename(s) under datasets/ to use (e.g. 'alpaca_unsloth.json').", + ), + output_dir: Path = typer.Option( + Path("./outputs"), + "--output-dir", + help="Where to store checkpoints.", + ), + training_type: str = typer.Option( + "lora", + "--training-type", + help="Training mode: 'lora' (LoRA/QLoRA) or 'full'.", + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), + num_epochs: int = typer.Option(3, "--epochs"), + learning_rate: float = typer.Option(2e-4, "--lr"), + batch_size: int = typer.Option(2, "--batch-size"), + gradient_accumulation_steps: int = typer.Option(4, "--grad-accum"), + warmup_steps: int = typer.Option(5, "--warmup-steps"), + max_steps: int = typer.Option(0, "--max-steps", help="Overrides epochs if >0."), + save_steps: int = typer.Option(0, "--save-steps", help="0 uses trainer defaults."), + weight_decay: float = typer.Option(0.01, "--weight-decay"), + random_seed: int = typer.Option(3407, "--seed"), + packing: bool = typer.Option(False, "--packing/--no-packing"), + train_on_completions: bool = typer.Option( + False, "--train-on-completions", help="Train on responses only when supported." + ), + lora_r: int = typer.Option(64, "--lora-r"), + lora_alpha: int = typer.Option(16, "--lora-alpha"), + lora_dropout: float = typer.Option(0.0, "--lora-dropout"), + gradient_checkpointing: bool = typer.Option( + True, "--gradient-checkpointing/--no-gradient-checkpointing" + ), + target_modules: str = typer.Option( + "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj", + "--target-modules", + help="Comma-separated target modules for LoRA.", + ), + use_rslora: bool = typer.Option(False, "--rslora/--no-rslora"), + use_loftq: bool = typer.Option(False, "--loftq/--no-loftq"), + enable_wandb: bool = typer.Option(False, "--wandb/--no-wandb"), + wandb_project: str = typer.Option("unsloth-training", "--wandb-project"), + wandb_token: Optional[str] = typer.Option( + None, "--wandb-token", envvar="WANDB_API_KEY" + ), + enable_tensorboard: bool = typer.Option( + False, "--tensorboard/--no-tensorboard", help="Enable TensorBoard logging." + ), + tensorboard_dir: str = typer.Option("runs", "--tensorboard-dir"), + format_type: str = typer.Option( + "auto", + "--format-type", + help="Dataset formatting: auto|alpaca|chatml|sharegpt.", + ), + verbose: bool = typer.Option(False, "--verbose/--quiet"), +): + """ + Launch training using the existing Unsloth training backend. + """ + if not dataset and not local_dataset: + typer.echo("Error: provide --dataset or --local-dataset", err=True) + raise typer.Exit(code=2) + + # Lazy imports to avoid triggering Unsloth patches on --help + from backend.trainer import UnslothTrainer + from backend.model_config import ModelConfig + + configure_logging(verbose) + trainer = UnslothTrainer() + + def progress_cb(progress: "TrainingProgress"): + _print_progress(progress) + + trainer.add_progress_callback(progress_cb) + + model_config = ModelConfig.from_ui_selection( + dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False + ) + if not model_config: + typer.echo("Could not resolve model config", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Loading model: {model_config.identifier}") + if not trainer.load_model( + model_name=model_config.identifier, + max_seq_length=max_seq_length, + load_in_4bit=load_in_4bit if training_type.lower() == "lora" else False, + hf_token=hf_token, + ): + typer.echo("Model load failed", err=True) + raise typer.Exit(code=1) + + use_lora = training_type.lower() == "lora" + typer.echo(f"Preparing model for {'LoRA' if use_lora else 'full'} finetuning...") + if not trainer.prepare_model_for_training( + use_lora=use_lora, + finetune_vision_layers=True, + finetune_language_layers=True, + finetune_attention_modules=True, + finetune_mlp_modules=True, + target_modules=[m.strip() for m in target_modules.split(",") if m.strip()], + lora_r=lora_r, + lora_alpha=lora_alpha, + lora_dropout=lora_dropout, + use_gradient_checkpointing=gradient_checkpointing, + use_rslora=use_rslora, + use_loftq=use_loftq, + ): + typer.echo("Model preparation failed", err=True) + raise typer.Exit(code=1) + + if not dataset and not local_dataset: + typer.echo("Provide --dataset or --local-dataset", err=True) + raise typer.Exit(code=2) + + typer.echo("Loading dataset...") + ds = trainer.load_and_format_dataset( + dataset_source=dataset or "", + format_type=format_type, + local_datasets=local_dataset, + ) + if ds is None: + typer.echo("Dataset load failed", err=True) + raise typer.Exit(code=1) + + typer.echo("Starting training...") + started = trainer.start_training( + dataset=ds, + output_dir=str(output_dir), + num_epochs=num_epochs, + learning_rate=learning_rate, + batch_size=batch_size, + gradient_accumulation_steps=gradient_accumulation_steps, + warmup_steps=warmup_steps, + max_steps=max_steps, + save_steps=save_steps, + weight_decay=weight_decay, + random_seed=random_seed, + packing=packing, + train_on_completions=train_on_completions, + enable_wandb=enable_wandb, + wandb_project=wandb_project, + wandb_token=wandb_token, + enable_tensorboard=enable_tensorboard, + tensorboard_dir=tensorboard_dir, + max_seq_length=max_seq_length, + ) + + if not started: + typer.echo("Training failed to start", err=True) + raise typer.Exit(code=1) + + try: + while trainer.training_thread and trainer.training_thread.is_alive(): + progress = trainer.get_training_progress() + _print_progress(progress) + time.sleep(5) + except KeyboardInterrupt: + typer.echo("Stopping training (Ctrl+C detected)...") + trainer.stop_training() + finally: + if trainer.training_thread: + trainer.training_thread.join() + + final = trainer.get_training_progress() + if final.error: + typer.echo(f"Training error: {final.error}", err=True) + raise typer.Exit(code=1) + typer.echo(final.status_message or "Training complete") + + +@app.command() +def chat( + model: str = typer.Argument(..., help="HF model id or local path."), + prompt: str = typer.Argument(..., help="User prompt to send."), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), + temperature: float = typer.Option(0.7, "--temperature"), + top_p: float = typer.Option(0.9, "--top-p"), + top_k: int = typer.Option(40, "--top-k"), + max_new_tokens: int = typer.Option(256, "--max-new-tokens"), + repetition_penalty: float = typer.Option(1.1, "--repetition-penalty"), + system_prompt: str = typer.Option( + "", + "--system-prompt", + help="Optional system prompt to prepend.", + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), + verbose: bool = typer.Option(False, "--verbose/--quiet"), +): + """ + Run a single chat turn using the inference backend. + """ + # Lazy imports to avoid triggering Unsloth patches on --help + from backend.model_config import ModelConfig + from backend.inference import get_inference_backend + + configure_logging(verbose) + inference_backend = get_inference_backend() + model_config = ModelConfig.from_ui_selection( + dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False + ) + if not model_config: + typer.echo("Could not resolve model config", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Loading model: {model_config.identifier}") + if not inference_backend.load_model( + config=model_config, + max_seq_length=max_seq_length, + load_in_4bit=load_in_4bit, + hf_token=hf_token, + ): + typer.echo("Model load failed", err=True) + raise typer.Exit(code=1) + + messages = [{"role": "user", "content": prompt}] + stream = inference_backend.generate_chat_response( + messages=messages, + system_prompt=system_prompt, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_new_tokens=max_new_tokens, + repetition_penalty=repetition_penalty, + ) + + typer.echo("Assistant:", nl=True) + for chunk in stream: + sys.stdout.write(chunk) + sys.stdout.flush() + sys.stdout.write("\n") + sys.stdout.flush() + + +@app.command("list-checkpoints") +def list_checkpoints( + outputs_dir: Path = typer.Option( + Path("./outputs"), "--outputs-dir", help="Directory that holds training runs." + ), +): + """ + List checkpoints detected in the outputs directory. + """ + from backend.export import ExportBackend + + backend = ExportBackend() + checkpoints = backend.scan_checkpoints(outputs_dir=str(outputs_dir)) + if not checkpoints: + typer.echo("No checkpoints found.") + raise typer.Exit() + + for display, path in checkpoints: + typer.echo(f"{display}: {path}") + + +if __name__ == "__main__": + app() From 4ef25032c19ce5293cd6c86aa427cee3393a1852 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 10:32:50 -0500 Subject: [PATCH 02/17] add config support + example configs, etc. --- cli.py | 438 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 306 insertions(+), 132 deletions(-) diff --git a/cli.py b/cli.py index 972e1884ad..10448480e0 100644 --- a/cli.py +++ b/cli.py @@ -1,18 +1,16 @@ +import json import logging import sys import time from pathlib import Path -from typing import Optional, List, TYPE_CHECKING +from typing import Optional, List import typer - -if TYPE_CHECKING: - # Only import for type hints to avoid triggering heavy backend initialization on CLI --help - from backend.trainer import TrainingProgress +import yaml app = typer.Typer( help="Command-line interface for Unsloth training, chat, and export.", - context_settings={"help_option_names": ["-h", "--help"]}, + context_settings={"help_option_names": ["-h", "--help"]}, ) @@ -25,33 +23,90 @@ def configure_logging(verbose: bool): ) -def _print_progress(progress): - parts = [] - if progress.step: - if progress.total_steps: - parts.append(f"step {progress.step}/{progress.total_steps}") - else: - parts.append(f"step {progress.step}") - if progress.epoch: - parts.append(f"epoch {progress.epoch}") - if progress.loss: - parts.append(f"loss {progress.loss:.4f}") - if progress.learning_rate: - parts.append(f"lr {progress.learning_rate:.2e}") - status = progress.status_message or "" - if not parts and status: - line = status +def _load_config(config_path: Optional[Path]) -> dict: + if not config_path: + return {} + path = Path(config_path) + if not path.exists(): + raise typer.BadParameter(f"Config file not found: {config_path}") + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + return yaml.safe_load(text) or {} else: - line = " | ".join(parts) - if status: - line = f"{line} | {status}" - if line: - typer.echo(line) + return json.loads(text or "{}") + + +def _flatten_config(cfg: dict) -> dict: + """ + Flatten nested config sections into a single dict. + + Expected sections: + data: dataset, local_dataset, format_type + training: training_type, max_seq_length, load_in_4bit, output_dir, etc. + lora: lora_r, lora_alpha, lora_dropout, target_modules, etc. + vision: finetune_vision_layers, finetune_language_layers, etc. + logging: enable_wandb, wandb_project, wandb_token, enable_tensorboard, etc. + """ + if not isinstance(cfg, dict): + return {} + + flattened = {} + + # Handle top-level 'model' key + if "model" in cfg: + flattened["model"] = cfg["model"] + + sections = ["data", "training", "lora", "vision", "logging"] + + for section in sections: + if section in cfg and isinstance(cfg[section], dict): + flattened.update(cfg[section]) + + return flattened + + +def _merge_config(cfg: dict, defaults: dict, overrides: dict) -> dict: + """ + Merge CLI overrides with config and defaults. + CLI override wins, then config value, then default. + """ + merged = {} + for key, default in defaults.items(): + cli_val = overrides.get(key, None) + if cli_val is not None: + merged[key] = cli_val + elif key in cfg and cfg[key] is not None: + merged[key] = cfg[key] + else: + merged[key] = default + return merged @app.command() def train( - model: str = typer.Argument(..., help="HF model id or local path."), + model: Optional[str] = typer.Option( + None, + "--model", + "-m", + help="HF model id or local path. Required unless provided in --config.", + ), + training_type: Optional[str] = typer.Option( + None, + "--training-type", + help="Training mode: 'lora' (LoRA/QLoRA) or 'full'. Defaults to 'lora'.", + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), + max_seq_length: Optional[int] = typer.Option(None, "--max-seq-length"), + load_in_4bit: Optional[bool] = typer.Option( + None, "--load-in-4bit/--no-load-in-4bit" + ), + output_dir: Optional[Path] = typer.Option( + None, + "--output-dir", + help="Where to store checkpoints. Defaults to ./outputs", + ), dataset: Optional[str] = typer.Option( None, "--dataset", @@ -63,68 +118,182 @@ def train( "--local-dataset", help="Filename(s) under datasets/ to use (e.g. 'alpaca_unsloth.json').", ), - output_dir: Path = typer.Option( - Path("./outputs"), - "--output-dir", - help="Where to store checkpoints.", + format_type: Optional[str] = typer.Option( + None, + "--format-type", + help="Dataset formatting: auto|alpaca|chatml|sharegpt. Defaults to auto.", ), - training_type: str = typer.Option( - "lora", - "--training-type", - help="Training mode: 'lora' (LoRA/QLoRA) or 'full'.", + num_epochs: Optional[int] = typer.Option(None, "--epochs"), + learning_rate: Optional[float] = typer.Option(None, "--lr"), + batch_size: Optional[int] = typer.Option(None, "--batch-size"), + gradient_accumulation_steps: Optional[int] = typer.Option(None, "--grad-accum"), + warmup_steps: Optional[int] = typer.Option(None, "--warmup-steps"), + max_steps: Optional[int] = typer.Option( + None, "--max-steps", help="Overrides epochs if >0." ), - hf_token: Optional[str] = typer.Option( - None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + save_steps: Optional[int] = typer.Option( + None, "--save-steps", help="0 uses trainer defaults." ), - max_seq_length: int = typer.Option(2048, "--max-seq-length"), - load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), - num_epochs: int = typer.Option(3, "--epochs"), - learning_rate: float = typer.Option(2e-4, "--lr"), - batch_size: int = typer.Option(2, "--batch-size"), - gradient_accumulation_steps: int = typer.Option(4, "--grad-accum"), - warmup_steps: int = typer.Option(5, "--warmup-steps"), - max_steps: int = typer.Option(0, "--max-steps", help="Overrides epochs if >0."), - save_steps: int = typer.Option(0, "--save-steps", help="0 uses trainer defaults."), - weight_decay: float = typer.Option(0.01, "--weight-decay"), - random_seed: int = typer.Option(3407, "--seed"), - packing: bool = typer.Option(False, "--packing/--no-packing"), - train_on_completions: bool = typer.Option( - False, "--train-on-completions", help="Train on responses only when supported." + weight_decay: Optional[float] = typer.Option(None, "--weight-decay"), + random_seed: Optional[int] = typer.Option(None, "--seed"), + packing: Optional[bool] = typer.Option(None, "--packing/--no-packing"), + train_on_completions: Optional[bool] = typer.Option( + None, "--train-on-completions", help="Train on responses only when supported." ), - lora_r: int = typer.Option(64, "--lora-r"), - lora_alpha: int = typer.Option(16, "--lora-alpha"), - lora_dropout: float = typer.Option(0.0, "--lora-dropout"), - gradient_checkpointing: bool = typer.Option( - True, "--gradient-checkpointing/--no-gradient-checkpointing" + lora_r: Optional[int] = typer.Option(None, "--lora-r"), + lora_alpha: Optional[int] = typer.Option(None, "--lora-alpha"), + lora_dropout: Optional[float] = typer.Option(None, "--lora-dropout"), + gradient_checkpointing: Optional[bool] = typer.Option( + None, "--gradient-checkpointing/--no-gradient-checkpointing" ), - target_modules: str = typer.Option( - "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj", + target_modules: Optional[str] = typer.Option( + None, "--target-modules", help="Comma-separated target modules for LoRA.", ), - use_rslora: bool = typer.Option(False, "--rslora/--no-rslora"), - use_loftq: bool = typer.Option(False, "--loftq/--no-loftq"), - enable_wandb: bool = typer.Option(False, "--wandb/--no-wandb"), - wandb_project: str = typer.Option("unsloth-training", "--wandb-project"), + vision_all_linear: Optional[bool] = typer.Option( + None, + "--vision-all-linear/--no-vision-all-linear", + help="For vision models, finetune all linear layers (mirrors UI toggle).", + ), + finetune_vision_layers: Optional[bool] = typer.Option( + None, + "--finetune-vision-layers/--no-finetune-vision-layers", + help="For vision LoRA: train vision layers.", + ), + finetune_language_layers: Optional[bool] = typer.Option( + None, + "--finetune-language-layers/--no-finetune-language-layers", + help="For vision LoRA: train language layers.", + ), + finetune_attention_modules: Optional[bool] = typer.Option( + None, + "--finetune-attention-modules/--no-finetune-attention-modules", + help="For vision LoRA: train attention modules.", + ), + finetune_mlp_modules: Optional[bool] = typer.Option( + None, + "--finetune-mlp-modules/--no-finetune-mlp-modules", + help="For vision LoRA: train MLP modules.", + ), + use_rslora: Optional[bool] = typer.Option(None, "--rslora/--no-rslora"), + use_loftq: Optional[bool] = typer.Option(None, "--loftq/--no-loftq"), + enable_wandb: Optional[bool] = typer.Option(None, "--wandb/--no-wandb"), + wandb_project: Optional[str] = typer.Option(None, "--wandb-project"), wandb_token: Optional[str] = typer.Option( None, "--wandb-token", envvar="WANDB_API_KEY" ), - enable_tensorboard: bool = typer.Option( - False, "--tensorboard/--no-tensorboard", help="Enable TensorBoard logging." + enable_tensorboard: Optional[bool] = typer.Option( + None, "--tensorboard/--no-tensorboard", help="Enable TensorBoard logging." ), - tensorboard_dir: str = typer.Option("runs", "--tensorboard-dir"), - format_type: str = typer.Option( - "auto", - "--format-type", - help="Dataset formatting: auto|alpaca|chatml|sharegpt.", + tensorboard_dir: Optional[str] = typer.Option(None, "--tensorboard-dir"), + config: Optional[Path] = typer.Option( + None, + "--config", + "-c", + help="Path to YAML/JSON config file. CLI flags override config values.", ), verbose: bool = typer.Option(False, "--verbose/--quiet"), ): """ Launch training using the existing Unsloth training backend. """ - if not dataset and not local_dataset: - typer.echo("Error: provide --dataset or --local-dataset", err=True) + cfg = _load_config(config) + cfg = _flatten_config(cfg) + + # Defaults (match previous behavior) + defaults = { + "model": None, + "training_type": "lora", + "max_seq_length": 2048, + "load_in_4bit": True, + "output_dir": Path("./outputs"), + "dataset": None, + "local_dataset": None, + "format_type": "auto", + "num_epochs": 3, + "learning_rate": 2e-4, + "batch_size": 2, + "gradient_accumulation_steps": 4, + "warmup_steps": 5, + "max_steps": 0, + "save_steps": 0, + "weight_decay": 0.01, + "random_seed": 3407, + "packing": False, + "train_on_completions": False, + "lora_r": 64, + "lora_alpha": 16, + "lora_dropout": 0.0, + "gradient_checkpointing": True, + "target_modules": "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj", + "vision_all_linear": False, + "finetune_vision_layers": True, + "finetune_language_layers": True, + "finetune_attention_modules": True, + "finetune_mlp_modules": True, + "use_rslora": False, + "use_loftq": False, + "enable_wandb": False, + "wandb_project": "unsloth-training", + "enable_tensorboard": False, + "tensorboard_dir": "runs", + } + + overrides = { + "training_type": training_type, + "max_seq_length": max_seq_length, + "load_in_4bit": load_in_4bit, + "output_dir": output_dir, + "dataset": dataset, + "local_dataset": local_dataset, + "format_type": format_type, + "num_epochs": num_epochs, + "learning_rate": learning_rate, + "batch_size": batch_size, + "gradient_accumulation_steps": gradient_accumulation_steps, + "warmup_steps": warmup_steps, + "max_steps": max_steps, + "save_steps": save_steps, + "weight_decay": weight_decay, + "random_seed": random_seed, + "packing": packing, + "train_on_completions": train_on_completions, + "lora_r": lora_r, + "lora_alpha": lora_alpha, + "lora_dropout": lora_dropout, + "gradient_checkpointing": gradient_checkpointing, + "target_modules": target_modules, + "vision_all_linear": vision_all_linear, + "finetune_vision_layers": finetune_vision_layers, + "finetune_language_layers": finetune_language_layers, + "finetune_attention_modules": finetune_attention_modules, + "finetune_mlp_modules": finetune_mlp_modules, + "use_rslora": use_rslora, + "use_loftq": use_loftq, + "enable_wandb": enable_wandb, + "wandb_project": wandb_project, + "wandb_token": wandb_token, + "enable_tensorboard": enable_tensorboard, + "tensorboard_dir": tensorboard_dir, + } + + merged = _merge_config(cfg, defaults, overrides) + + model_val = merged.get("model") + if not model_val: + typer.echo("Error: provide --model or set model in --config", err=True) + raise typer.Exit(code=2) + + # Convert specific types + output_dir_val = Path(merged["output_dir"]) + dataset_val = merged.get("dataset") + local_dataset_val = merged.get("local_dataset") + + if not dataset_val and not local_dataset_val: + typer.echo( + "Error: provide --dataset or --local-dataset (or via --config)", err=True + ) raise typer.Exit(code=2) # Lazy imports to avoid triggering Unsloth patches on --help @@ -134,82 +303,86 @@ def train( configure_logging(verbose) trainer = UnslothTrainer() - def progress_cb(progress: "TrainingProgress"): - _print_progress(progress) - - trainer.add_progress_callback(progress_cb) - model_config = ModelConfig.from_ui_selection( - dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False + dropdown_value=model_val, search_value=None, hf_token=hf_token, is_lora=False ) if not model_config: typer.echo("Could not resolve model config", err=True) raise typer.Exit(code=1) - typer.echo(f"Loading model: {model_config.identifier}") + is_vision = model_config.is_vision + if not trainer.load_model( model_name=model_config.identifier, - max_seq_length=max_seq_length, - load_in_4bit=load_in_4bit if training_type.lower() == "lora" else False, + max_seq_length=merged["max_seq_length"], + load_in_4bit=merged["load_in_4bit"] + if merged["training_type"].lower() == "lora" + else False, hf_token=hf_token, ): typer.echo("Model load failed", err=True) raise typer.Exit(code=1) - use_lora = training_type.lower() == "lora" - typer.echo(f"Preparing model for {'LoRA' if use_lora else 'full'} finetuning...") + use_lora = merged["training_type"].lower() == "lora" + + # Match UI behavior for target modules: + # - Text: use parsed target modules list + # - Vision: if vision_all_linear, use ["all-linear"]; otherwise empty list + target_modules_list = [ + m.strip() for m in merged["target_modules"].split(",") if m.strip() + ] + if use_lora and is_vision: + if merged["vision_all_linear"]: + target_modules_list = ["all-linear"] + else: + target_modules_list = [] + if not trainer.prepare_model_for_training( use_lora=use_lora, - finetune_vision_layers=True, - finetune_language_layers=True, - finetune_attention_modules=True, - finetune_mlp_modules=True, - target_modules=[m.strip() for m in target_modules.split(",") if m.strip()], - lora_r=lora_r, - lora_alpha=lora_alpha, - lora_dropout=lora_dropout, - use_gradient_checkpointing=gradient_checkpointing, - use_rslora=use_rslora, - use_loftq=use_loftq, + finetune_vision_layers=merged["finetune_vision_layers"], + finetune_language_layers=merged["finetune_language_layers"], + finetune_attention_modules=merged["finetune_attention_modules"], + finetune_mlp_modules=merged["finetune_mlp_modules"], + target_modules=target_modules_list, + lora_r=merged["lora_r"], + lora_alpha=merged["lora_alpha"], + lora_dropout=merged["lora_dropout"], + use_gradient_checkpointing=merged["gradient_checkpointing"], + use_rslora=merged["use_rslora"], + use_loftq=merged["use_loftq"], ): typer.echo("Model preparation failed", err=True) raise typer.Exit(code=1) - if not dataset and not local_dataset: - typer.echo("Provide --dataset or --local-dataset", err=True) - raise typer.Exit(code=2) - - typer.echo("Loading dataset...") ds = trainer.load_and_format_dataset( - dataset_source=dataset or "", - format_type=format_type, - local_datasets=local_dataset, + dataset_source=dataset_val or "", + format_type=merged["format_type"], + local_datasets=local_dataset_val, ) if ds is None: typer.echo("Dataset load failed", err=True) raise typer.Exit(code=1) - typer.echo("Starting training...") started = trainer.start_training( dataset=ds, - output_dir=str(output_dir), - num_epochs=num_epochs, - learning_rate=learning_rate, - batch_size=batch_size, - gradient_accumulation_steps=gradient_accumulation_steps, - warmup_steps=warmup_steps, - max_steps=max_steps, - save_steps=save_steps, - weight_decay=weight_decay, - random_seed=random_seed, - packing=packing, - train_on_completions=train_on_completions, - enable_wandb=enable_wandb, - wandb_project=wandb_project, - wandb_token=wandb_token, - enable_tensorboard=enable_tensorboard, - tensorboard_dir=tensorboard_dir, - max_seq_length=max_seq_length, + output_dir=str(output_dir_val), + num_epochs=merged["num_epochs"], + learning_rate=merged["learning_rate"], + batch_size=merged["batch_size"], + gradient_accumulation_steps=merged["gradient_accumulation_steps"], + warmup_steps=merged["warmup_steps"], + max_steps=merged["max_steps"], + save_steps=merged["save_steps"], + weight_decay=merged["weight_decay"], + random_seed=merged["random_seed"], + packing=merged["packing"], + train_on_completions=merged["train_on_completions"], + enable_wandb=merged["enable_wandb"], + wandb_project=merged["wandb_project"], + wandb_token=merged.get("wandb_token"), + enable_tensorboard=merged["enable_tensorboard"], + tensorboard_dir=merged["tensorboard_dir"], + max_seq_length=merged["max_seq_length"], ) if not started: @@ -218,9 +391,7 @@ def train( try: while trainer.training_thread and trainer.training_thread.is_alive(): - progress = trainer.get_training_progress() - _print_progress(progress) - time.sleep(5) + time.sleep(1) except KeyboardInterrupt: typer.echo("Stopping training (Ctrl+C detected)...") trainer.stop_training() @@ -229,16 +400,15 @@ def train( trainer.training_thread.join() final = trainer.get_training_progress() - if final.error: + if getattr(final, "error", None): typer.echo(f"Training error: {final.error}", err=True) raise typer.Exit(code=1) - typer.echo(final.status_message or "Training complete") @app.command() -def chat( +def inference( model: str = typer.Argument(..., help="HF model id or local path."), - prompt: str = typer.Argument(..., help="User prompt to send."), + prompt: str = typer.Argument(..., help="Prompt to send to the model."), hf_token: Optional[str] = typer.Option( None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." ), @@ -257,7 +427,7 @@ def chat( verbose: bool = typer.Option(False, "--verbose/--quiet"), ): """ - Run a single chat turn using the inference backend. + Run a single inference using the specified model. """ # Lazy imports to avoid triggering Unsloth patches on --help from backend.model_config import ModelConfig @@ -272,7 +442,6 @@ def chat( typer.echo("Could not resolve model config", err=True) raise typer.Exit(code=1) - typer.echo(f"Loading model: {model_config.identifier}") if not inference_backend.load_model( config=model_config, max_seq_length=max_seq_length, @@ -294,9 +463,14 @@ def chat( ) typer.echo("Assistant:", nl=True) + previous = "" for chunk in stream: - sys.stdout.write(chunk) - sys.stdout.flush() + # Backend yields cumulative text; print only the delta + delta = chunk[len(previous):] + if delta: + sys.stdout.write(delta) + sys.stdout.flush() + previous = chunk sys.stdout.write("\n") sys.stdout.flush() From 22f9a65772eb8c7ebe923bd409b049754adf1ec8 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 11:52:08 -0500 Subject: [PATCH 03/17] refactor --- cli.py | 228 +++++------------------------------------------- cli/__init__.py | 0 cli/config.py | 142 ++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 206 deletions(-) create mode 100644 cli/__init__.py create mode 100644 cli/config.py diff --git a/cli.py b/cli.py index 10448480e0..14e5e8eb59 100644 --- a/cli.py +++ b/cli.py @@ -1,4 +1,3 @@ -import json import logging import sys import time @@ -6,7 +5,8 @@ from pathlib import Path from typing import Optional, List import typer -import yaml + +from cli.config import Config, load_config app = typer.Typer( help="Command-line interface for Unsloth training, chat, and export.", @@ -23,65 +23,6 @@ def configure_logging(verbose: bool): ) -def _load_config(config_path: Optional[Path]) -> dict: - if not config_path: - return {} - path = Path(config_path) - if not path.exists(): - raise typer.BadParameter(f"Config file not found: {config_path}") - text = path.read_text(encoding="utf-8") - if path.suffix.lower() in {".yaml", ".yml"}: - return yaml.safe_load(text) or {} - else: - return json.loads(text or "{}") - - -def _flatten_config(cfg: dict) -> dict: - """ - Flatten nested config sections into a single dict. - - Expected sections: - data: dataset, local_dataset, format_type - training: training_type, max_seq_length, load_in_4bit, output_dir, etc. - lora: lora_r, lora_alpha, lora_dropout, target_modules, etc. - vision: finetune_vision_layers, finetune_language_layers, etc. - logging: enable_wandb, wandb_project, wandb_token, enable_tensorboard, etc. - """ - if not isinstance(cfg, dict): - return {} - - flattened = {} - - # Handle top-level 'model' key - if "model" in cfg: - flattened["model"] = cfg["model"] - - sections = ["data", "training", "lora", "vision", "logging"] - - for section in sections: - if section in cfg and isinstance(cfg[section], dict): - flattened.update(cfg[section]) - - return flattened - - -def _merge_config(cfg: dict, defaults: dict, overrides: dict) -> dict: - """ - Merge CLI overrides with config and defaults. - CLI override wins, then config value, then default. - """ - merged = {} - for key, default in defaults.items(): - cli_val = overrides.get(key, None) - if cli_val is not None: - merged[key] = cli_val - elif key in cfg and cfg[key] is not None: - merged[key] = cfg[key] - else: - merged[key] = default - return merged - - @app.command() def train( model: Optional[str] = typer.Option( @@ -198,99 +139,22 @@ def train( """ Launch training using the existing Unsloth training backend. """ - cfg = _load_config(config) - cfg = _flatten_config(cfg) + try: + cfg = load_config(config) + except FileNotFoundError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=2) - # Defaults (match previous behavior) - defaults = { - "model": None, - "training_type": "lora", - "max_seq_length": 2048, - "load_in_4bit": True, - "output_dir": Path("./outputs"), - "dataset": None, - "local_dataset": None, - "format_type": "auto", - "num_epochs": 3, - "learning_rate": 2e-4, - "batch_size": 2, - "gradient_accumulation_steps": 4, - "warmup_steps": 5, - "max_steps": 0, - "save_steps": 0, - "weight_decay": 0.01, - "random_seed": 3407, - "packing": False, - "train_on_completions": False, - "lora_r": 64, - "lora_alpha": 16, - "lora_dropout": 0.0, - "gradient_checkpointing": True, - "target_modules": "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj", - "vision_all_linear": False, - "finetune_vision_layers": True, - "finetune_language_layers": True, - "finetune_attention_modules": True, - "finetune_mlp_modules": True, - "use_rslora": False, - "use_loftq": False, - "enable_wandb": False, - "wandb_project": "unsloth-training", - "enable_tensorboard": False, - "tensorboard_dir": "runs", - } + # Apply CLI overrides + cli_args = {k: v for k, v in locals().items() if k not in ("config", "verbose", "hf_token", "cfg")} + cfg.apply_overrides(**cli_args) - overrides = { - "training_type": training_type, - "max_seq_length": max_seq_length, - "load_in_4bit": load_in_4bit, - "output_dir": output_dir, - "dataset": dataset, - "local_dataset": local_dataset, - "format_type": format_type, - "num_epochs": num_epochs, - "learning_rate": learning_rate, - "batch_size": batch_size, - "gradient_accumulation_steps": gradient_accumulation_steps, - "warmup_steps": warmup_steps, - "max_steps": max_steps, - "save_steps": save_steps, - "weight_decay": weight_decay, - "random_seed": random_seed, - "packing": packing, - "train_on_completions": train_on_completions, - "lora_r": lora_r, - "lora_alpha": lora_alpha, - "lora_dropout": lora_dropout, - "gradient_checkpointing": gradient_checkpointing, - "target_modules": target_modules, - "vision_all_linear": vision_all_linear, - "finetune_vision_layers": finetune_vision_layers, - "finetune_language_layers": finetune_language_layers, - "finetune_attention_modules": finetune_attention_modules, - "finetune_mlp_modules": finetune_mlp_modules, - "use_rslora": use_rslora, - "use_loftq": use_loftq, - "enable_wandb": enable_wandb, - "wandb_project": wandb_project, - "wandb_token": wandb_token, - "enable_tensorboard": enable_tensorboard, - "tensorboard_dir": tensorboard_dir, - } - - merged = _merge_config(cfg, defaults, overrides) - - model_val = merged.get("model") - if not model_val: + # Validate required fields + if not cfg.model: typer.echo("Error: provide --model or set model in --config", err=True) raise typer.Exit(code=2) - # Convert specific types - output_dir_val = Path(merged["output_dir"]) - dataset_val = merged.get("dataset") - local_dataset_val = merged.get("local_dataset") - - if not dataset_val and not local_dataset_val: + if not cfg.data.dataset and not cfg.data.local_dataset: typer.echo( "Error: provide --dataset or --local-dataset (or via --config)", err=True ) @@ -304,86 +168,38 @@ def train( trainer = UnslothTrainer() model_config = ModelConfig.from_ui_selection( - dropdown_value=model_val, search_value=None, hf_token=hf_token, is_lora=False + dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=False ) if not model_config: typer.echo("Could not resolve model config", err=True) raise typer.Exit(code=1) is_vision = model_config.is_vision + use_lora = cfg.training.training_type.lower() == "lora" if not trainer.load_model( model_name=model_config.identifier, - max_seq_length=merged["max_seq_length"], - load_in_4bit=merged["load_in_4bit"] - if merged["training_type"].lower() == "lora" - else False, + max_seq_length=cfg.training.max_seq_length, + load_in_4bit=cfg.training.load_in_4bit if use_lora else False, hf_token=hf_token, ): typer.echo("Model load failed", err=True) raise typer.Exit(code=1) - use_lora = merged["training_type"].lower() == "lora" - - # Match UI behavior for target modules: - # - Text: use parsed target modules list - # - Vision: if vision_all_linear, use ["all-linear"]; otherwise empty list - target_modules_list = [ - m.strip() for m in merged["target_modules"].split(",") if m.strip() - ] - if use_lora and is_vision: - if merged["vision_all_linear"]: - target_modules_list = ["all-linear"] - else: - target_modules_list = [] - - if not trainer.prepare_model_for_training( - use_lora=use_lora, - finetune_vision_layers=merged["finetune_vision_layers"], - finetune_language_layers=merged["finetune_language_layers"], - finetune_attention_modules=merged["finetune_attention_modules"], - finetune_mlp_modules=merged["finetune_mlp_modules"], - target_modules=target_modules_list, - lora_r=merged["lora_r"], - lora_alpha=merged["lora_alpha"], - lora_dropout=merged["lora_dropout"], - use_gradient_checkpointing=merged["gradient_checkpointing"], - use_rslora=merged["use_rslora"], - use_loftq=merged["use_loftq"], - ): + if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)): typer.echo("Model preparation failed", err=True) raise typer.Exit(code=1) ds = trainer.load_and_format_dataset( - dataset_source=dataset_val or "", - format_type=merged["format_type"], - local_datasets=local_dataset_val, + dataset_source=cfg.data.dataset or "", + format_type=cfg.data.format_type, + local_datasets=cfg.data.local_dataset, ) if ds is None: typer.echo("Dataset load failed", err=True) raise typer.Exit(code=1) - started = trainer.start_training( - dataset=ds, - output_dir=str(output_dir_val), - num_epochs=merged["num_epochs"], - learning_rate=merged["learning_rate"], - batch_size=merged["batch_size"], - gradient_accumulation_steps=merged["gradient_accumulation_steps"], - warmup_steps=merged["warmup_steps"], - max_steps=merged["max_steps"], - save_steps=merged["save_steps"], - weight_decay=merged["weight_decay"], - random_seed=merged["random_seed"], - packing=merged["packing"], - train_on_completions=merged["train_on_completions"], - enable_wandb=merged["enable_wandb"], - wandb_project=merged["wandb_project"], - wandb_token=merged.get("wandb_token"), - enable_tensorboard=merged["enable_tensorboard"], - tensorboard_dir=merged["tensorboard_dir"], - max_seq_length=merged["max_seq_length"], - ) + started = trainer.start_training(dataset=ds, **cfg.training_kwargs()) if not started: typer.echo("Training failed to start", err=True) diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000000..bcfa02153b --- /dev/null +++ b/cli/config.py @@ -0,0 +1,142 @@ +from pathlib import Path +from typing import Optional, List + +import yaml +from pydantic import BaseModel, Field + + +class DataConfig(BaseModel): + dataset: Optional[str] = None + local_dataset: Optional[List[str]] = None + format_type: str = "auto" + + +class TrainingConfig(BaseModel): + training_type: str = "lora" + max_seq_length: int = 2048 + load_in_4bit: bool = True + output_dir: Path = Path("./outputs") + num_epochs: int = 3 + learning_rate: float = 2e-4 + batch_size: int = 2 + gradient_accumulation_steps: int = 4 + warmup_steps: int = 5 + max_steps: int = 0 + save_steps: int = 0 + weight_decay: float = 0.01 + random_seed: int = 3407 + packing: bool = False + train_on_completions: bool = False + gradient_checkpointing: bool = True + + +class LoraConfig(BaseModel): + lora_r: int = 64 + lora_alpha: int = 16 + lora_dropout: float = 0.0 + target_modules: str = "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj" + vision_all_linear: bool = False + use_rslora: bool = False + use_loftq: bool = False + + +class VisionConfig(BaseModel): + finetune_vision_layers: bool = True + finetune_language_layers: bool = True + finetune_attention_modules: bool = True + finetune_mlp_modules: bool = True + + +class LoggingConfig(BaseModel): + enable_wandb: bool = False + wandb_project: str = "unsloth-training" + wandb_token: Optional[str] = None + enable_tensorboard: bool = False + tensorboard_dir: str = "runs" + + +class Config(BaseModel): + model: Optional[str] = None + data: DataConfig = Field(default_factory=DataConfig) + training: TrainingConfig = Field(default_factory=TrainingConfig) + lora: LoraConfig = Field(default_factory=LoraConfig) + vision: VisionConfig = Field(default_factory=VisionConfig) + logging: LoggingConfig = Field(default_factory=LoggingConfig) + + def apply_overrides(self, **kwargs): + """Apply CLI overrides by matching arg names to config fields.""" + for key, value in kwargs.items(): + if value is None: + continue + if hasattr(self, key): + setattr(self, key, value) + else: + for section in (self.data, self.training, self.lora, self.vision, self.logging): + if hasattr(section, key): + setattr(section, key, value) + break + + def model_kwargs(self, use_lora: bool, is_vision: bool) -> dict: + """Return kwargs for trainer.prepare_model_for_training().""" + # Determine target modules based on model type + if use_lora and is_vision: + target_modules = ["all-linear"] if self.lora.vision_all_linear else [] + else: + target_modules = [m.strip() for m in self.lora.target_modules.split(",") if m.strip()] + + return { + "use_lora": use_lora, + "finetune_vision_layers": self.vision.finetune_vision_layers, + "finetune_language_layers": self.vision.finetune_language_layers, + "finetune_attention_modules": self.vision.finetune_attention_modules, + "finetune_mlp_modules": self.vision.finetune_mlp_modules, + "target_modules": target_modules, + "lora_r": self.lora.lora_r, + "lora_alpha": self.lora.lora_alpha, + "lora_dropout": self.lora.lora_dropout, + "use_gradient_checkpointing": self.training.gradient_checkpointing, + "use_rslora": self.lora.use_rslora, + "use_loftq": self.lora.use_loftq, + } + + def training_kwargs(self) -> dict: + """Return kwargs for trainer.start_training().""" + return { + "output_dir": str(self.training.output_dir), + "num_epochs": self.training.num_epochs, + "learning_rate": self.training.learning_rate, + "batch_size": self.training.batch_size, + "gradient_accumulation_steps": self.training.gradient_accumulation_steps, + "warmup_steps": self.training.warmup_steps, + "max_steps": self.training.max_steps, + "save_steps": self.training.save_steps, + "weight_decay": self.training.weight_decay, + "random_seed": self.training.random_seed, + "packing": self.training.packing, + "train_on_completions": self.training.train_on_completions, + "max_seq_length": self.training.max_seq_length, + "enable_wandb": self.logging.enable_wandb, + "wandb_project": self.logging.wandb_project, + "wandb_token": self.logging.wandb_token, + "enable_tensorboard": self.logging.enable_tensorboard, + "tensorboard_dir": self.logging.tensorboard_dir, + } + + +def load_config(path: Optional[Path]) -> Config: + """Load config from YAML/JSON file, or return defaults if no path given.""" + if not path: + return Config() + + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + data = yaml.safe_load(text) or {} + else: + import json + data = json.loads(text or "{}") + + return Config(**data) From 356fb08b0378b6f98de38ef3cd2020f316f2455c Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 12:03:05 -0500 Subject: [PATCH 04/17] add dry run --- cli.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 14e5e8eb59..0772a4942e 100644 --- a/cli.py +++ b/cli.py @@ -8,6 +8,9 @@ import typer from cli.config import Config, load_config +# CLI args that should not be passed to cfg.apply_overrides() +_EXCLUDED_CLI_ARGS = ("config", "dry_run", "verbose", "hf_token", "cfg") + app = typer.Typer( help="Command-line interface for Unsloth training, chat, and export.", context_settings={"help_option_names": ["-h", "--help"]}, @@ -134,6 +137,11 @@ def train( "-c", help="Path to YAML/JSON config file. CLI flags override config values.", ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show resolved config and exit without training.", + ), verbose: bool = typer.Option(False, "--verbose/--quiet"), ): """ @@ -146,9 +154,17 @@ def train( raise typer.Exit(code=2) # Apply CLI overrides - cli_args = {k: v for k, v in locals().items() if k not in ("config", "verbose", "hf_token", "cfg")} + cli_args = {k: v for k, v in locals().items() if k not in _EXCLUDED_CLI_ARGS} cfg.apply_overrides(**cli_args) + # Dry run: show resolved config and exit + if dry_run: + import yaml + data = cfg.model_dump() + data["training"]["output_dir"] = str(data["training"]["output_dir"]) + typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False)) + raise typer.Exit(code=0) + # Validate required fields if not cfg.model: typer.echo("Error: provide --model or set model in --config", err=True) From cf966fe98e34e65bdad55ecd3d0dc55e42013078 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 12:28:22 -0500 Subject: [PATCH 05/17] autogen typer options from pydantic models --- cli.py | 117 +++------------------------------------- cli/options.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 109 deletions(-) create mode 100644 cli/options.py diff --git a/cli.py b/cli.py index 0772a4942e..c820a8d73b 100644 --- a/cli.py +++ b/cli.py @@ -2,14 +2,12 @@ import logging import sys import time from pathlib import Path -from typing import Optional, List +from typing import Optional import typer from cli.config import Config, load_config - -# CLI args that should not be passed to cfg.apply_overrides() -_EXCLUDED_CLI_ARGS = ("config", "dry_run", "verbose", "hf_token", "cfg") +from cli.options import add_options_from_config app = typer.Typer( help="Command-line interface for Unsloth training, chat, and export.", @@ -27,122 +25,24 @@ def configure_logging(verbose: bool): @app.command() +@add_options_from_config(Config) def train( - model: Optional[str] = typer.Option( - None, - "--model", - "-m", - help="HF model id or local path. Required unless provided in --config.", - ), - training_type: Optional[str] = typer.Option( - None, - "--training-type", - help="Training mode: 'lora' (LoRA/QLoRA) or 'full'. Defaults to 'lora'.", - ), - hf_token: Optional[str] = typer.Option( - None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." - ), - max_seq_length: Optional[int] = typer.Option(None, "--max-seq-length"), - load_in_4bit: Optional[bool] = typer.Option( - None, "--load-in-4bit/--no-load-in-4bit" - ), - output_dir: Optional[Path] = typer.Option( - None, - "--output-dir", - help="Where to store checkpoints. Defaults to ./outputs", - ), - dataset: Optional[str] = typer.Option( - None, - "--dataset", - "-d", - help="HF dataset to train on (e.g. 'tatsu-lab/alpaca').", - ), - local_dataset: Optional[List[str]] = typer.Option( - None, - "--local-dataset", - help="Filename(s) under datasets/ to use (e.g. 'alpaca_unsloth.json').", - ), - format_type: Optional[str] = typer.Option( - None, - "--format-type", - help="Dataset formatting: auto|alpaca|chatml|sharegpt. Defaults to auto.", - ), - num_epochs: Optional[int] = typer.Option(None, "--epochs"), - learning_rate: Optional[float] = typer.Option(None, "--lr"), - batch_size: Optional[int] = typer.Option(None, "--batch-size"), - gradient_accumulation_steps: Optional[int] = typer.Option(None, "--grad-accum"), - warmup_steps: Optional[int] = typer.Option(None, "--warmup-steps"), - max_steps: Optional[int] = typer.Option( - None, "--max-steps", help="Overrides epochs if >0." - ), - save_steps: Optional[int] = typer.Option( - None, "--save-steps", help="0 uses trainer defaults." - ), - weight_decay: Optional[float] = typer.Option(None, "--weight-decay"), - random_seed: Optional[int] = typer.Option(None, "--seed"), - packing: Optional[bool] = typer.Option(None, "--packing/--no-packing"), - train_on_completions: Optional[bool] = typer.Option( - None, "--train-on-completions", help="Train on responses only when supported." - ), - lora_r: Optional[int] = typer.Option(None, "--lora-r"), - lora_alpha: Optional[int] = typer.Option(None, "--lora-alpha"), - lora_dropout: Optional[float] = typer.Option(None, "--lora-dropout"), - gradient_checkpointing: Optional[bool] = typer.Option( - None, "--gradient-checkpointing/--no-gradient-checkpointing" - ), - target_modules: Optional[str] = typer.Option( - None, - "--target-modules", - help="Comma-separated target modules for LoRA.", - ), - vision_all_linear: Optional[bool] = typer.Option( - None, - "--vision-all-linear/--no-vision-all-linear", - help="For vision models, finetune all linear layers (mirrors UI toggle).", - ), - finetune_vision_layers: Optional[bool] = typer.Option( - None, - "--finetune-vision-layers/--no-finetune-vision-layers", - help="For vision LoRA: train vision layers.", - ), - finetune_language_layers: Optional[bool] = typer.Option( - None, - "--finetune-language-layers/--no-finetune-language-layers", - help="For vision LoRA: train language layers.", - ), - finetune_attention_modules: Optional[bool] = typer.Option( - None, - "--finetune-attention-modules/--no-finetune-attention-modules", - help="For vision LoRA: train attention modules.", - ), - finetune_mlp_modules: Optional[bool] = typer.Option( - None, - "--finetune-mlp-modules/--no-finetune-mlp-modules", - help="For vision LoRA: train MLP modules.", - ), - use_rslora: Optional[bool] = typer.Option(None, "--rslora/--no-rslora"), - use_loftq: Optional[bool] = typer.Option(None, "--loftq/--no-loftq"), - enable_wandb: Optional[bool] = typer.Option(None, "--wandb/--no-wandb"), - wandb_project: Optional[str] = typer.Option(None, "--wandb-project"), - wandb_token: Optional[str] = typer.Option( - None, "--wandb-token", envvar="WANDB_API_KEY" - ), - enable_tensorboard: Optional[bool] = typer.Option( - None, "--tensorboard/--no-tensorboard", help="Enable TensorBoard logging." - ), - tensorboard_dir: Optional[str] = typer.Option(None, "--tensorboard-dir"), config: Optional[Path] = typer.Option( None, "--config", "-c", help="Path to YAML/JSON config file. CLI flags override config values.", ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), dry_run: bool = typer.Option( False, "--dry-run", help="Show resolved config and exit without training.", ), verbose: bool = typer.Option(False, "--verbose/--quiet"), + config_overrides: dict = None, # Injected by decorator ): """ Launch training using the existing Unsloth training backend. @@ -154,8 +54,7 @@ def train( raise typer.Exit(code=2) # Apply CLI overrides - cli_args = {k: v for k, v in locals().items() if k not in _EXCLUDED_CLI_ARGS} - cfg.apply_overrides(**cli_args) + cfg.apply_overrides(**config_overrides) # Dry run: show resolved config and exit if dry_run: diff --git a/cli/options.py b/cli/options.py new file mode 100644 index 0000000000..18336efca7 --- /dev/null +++ b/cli/options.py @@ -0,0 +1,143 @@ +"""Generate Typer CLI options from Pydantic models.""" + +import functools +import inspect +from pathlib import Path +from typing import Any, Callable, Optional, get_args, get_origin + +import typer +from pydantic import BaseModel + + +def _python_name_to_cli_flag(name: str) -> str: + """Convert python_name to --cli-flag.""" + return "--" + name.replace("_", "-") + + +def _unwrap_optional(annotation: Any) -> Any: + """Unwrap Optional[X] to X.""" + origin = get_origin(annotation) + if origin is not None: + args = get_args(annotation) + if type(None) in args: + non_none = [a for a in args if a is not type(None)] + if non_none: + return non_none[0] + return annotation + + +def _is_bool_field(annotation: Any) -> bool: + """Check if field is a boolean (including Optional[bool]).""" + return _unwrap_optional(annotation) is bool + + +def _is_list_type(annotation: Any) -> bool: + """Check if type is a List.""" + return get_origin(annotation) is list + + +def _get_python_type(annotation: Any) -> type: + """Get the Python type for annotation.""" + unwrapped = _unwrap_optional(annotation) + if unwrapped in (str, int, float, bool, Path): + return unwrapped + return str + + +def _collect_config_fields(config_class: type[BaseModel]) -> list[tuple[str, Any]]: + """ + Collect all fields from a config class, flattening nested models. + Returns list of (name, field_info) tuples. + Raises ValueError on duplicate field names. + """ + fields = [] + seen_names: set[str] = set() + + for name, field_info in config_class.model_fields.items(): + annotation = field_info.annotation + # Skip nested models - recurse into them + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + for nested_name, nested_field in annotation.model_fields.items(): + if nested_name in seen_names: + raise ValueError(f"Duplicate field name '{nested_name}' in config") + seen_names.add(nested_name) + fields.append((nested_name, nested_field)) + else: + if name in seen_names: + raise ValueError(f"Duplicate field name '{name}' in config") + seen_names.add(name) + fields.append((name, field_info)) + return fields + + +def add_options_from_config(config_class: type[BaseModel]) -> Callable: + """ + Decorator that adds CLI options for all fields in a Pydantic config model. + + The decorated function should declare a `config_overrides: dict = None` parameter + which will receive a dict of all CLI-provided config values. + """ + fields = _collect_config_fields(config_class) + field_names = {name for name, field_info in fields if not _is_list_type(field_info.annotation)} + + def decorator(func: Callable) -> Callable: + sig = inspect.signature(func) + original_params = list(sig.parameters.values()) + + # Build new parameters: config fields first, then original params + new_params = [] + + for field_name, field_info in fields: + annotation = field_info.annotation + if _is_list_type(annotation): + continue + + flag_name = _python_name_to_cli_flag(field_name) + help_text = field_info.description or "" + + if _is_bool_field(annotation): + default = typer.Option( + None, + f"{flag_name}/--no-{field_name.replace('_', '-')}", + help=help_text, + ) + param = inspect.Parameter( + field_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=default, + annotation=Optional[bool], + ) + else: + py_type = _get_python_type(annotation) + default = typer.Option(None, flag_name, help=help_text) + param = inspect.Parameter( + field_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=default, + annotation=Optional[py_type], + ) + new_params.append(param) + + # Add original params, excluding config_overrides (will be injected) + for param in original_params: + if param.name != "config_overrides": + new_params.append(param) + + new_sig = sig.replace(parameters=new_params) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + config_overrides = {} + for key in list(kwargs.keys()): + if key in field_names: + if kwargs[key] is not None: + config_overrides[key] = kwargs[key] + del kwargs[key] + + kwargs["config_overrides"] = config_overrides + return func(*args, **kwargs) + + wrapper.__signature__ = new_sig + return wrapper + + return decorator From 7828f77175f682e2cf52d462d2f923905b46086c Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 13:20:33 -0500 Subject: [PATCH 06/17] fixes / cleanup --- cli.py | 24 +++--------------------- cli/config.py | 2 +- cli/options.py | 5 ++--- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/cli.py b/cli.py index c820a8d73b..f960eee95f 100644 --- a/cli.py +++ b/cli.py @@ -1,4 +1,3 @@ -import logging import sys import time from pathlib import Path @@ -15,15 +14,6 @@ app = typer.Typer( ) -def configure_logging(verbose: bool): - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", - datefmt="%H:%M:%S", - ) - - @app.command() @add_options_from_config(Config) def train( @@ -41,12 +31,9 @@ def train( "--dry-run", help="Show resolved config and exit without training.", ), - verbose: bool = typer.Option(False, "--verbose/--quiet"), - config_overrides: dict = None, # Injected by decorator + config_overrides: dict = None, # Injected by add_options_from_config decorator ): - """ - Launch training using the existing Unsloth training backend. - """ + """Launch training using the existing Unsloth training backend.""" try: cfg = load_config(config) except FileNotFoundError as e: @@ -79,7 +66,6 @@ def train( from backend.trainer import UnslothTrainer from backend.model_config import ModelConfig - configure_logging(verbose) trainer = UnslothTrainer() model_config = ModelConfig.from_ui_selection( @@ -155,16 +141,12 @@ def inference( ), max_seq_length: int = typer.Option(2048, "--max-seq-length"), load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), - verbose: bool = typer.Option(False, "--verbose/--quiet"), ): - """ - Run a single inference using the specified model. - """ + """Run a single inference using the specified model.""" # Lazy imports to avoid triggering Unsloth patches on --help from backend.model_config import ModelConfig from backend.inference import get_inference_backend - configure_logging(verbose) inference_backend = get_inference_backend() model_config = ModelConfig.from_ui_selection( dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False diff --git a/cli/config.py b/cli/config.py index bcfa02153b..d80f3a2f63 100644 --- a/cli/config.py +++ b/cli/config.py @@ -80,7 +80,7 @@ class Config(BaseModel): """Return kwargs for trainer.prepare_model_for_training().""" # Determine target modules based on model type if use_lora and is_vision: - target_modules = ["all-linear"] if self.lora.vision_all_linear else [] + target_modules = "all-linear" if self.lora.vision_all_linear else [] else: target_modules = [m.strip() for m in self.lora.target_modules.split(",") if m.strip()] diff --git a/cli/options.py b/cli/options.py index 18336efca7..e9269a6cd1 100644 --- a/cli/options.py +++ b/cli/options.py @@ -46,9 +46,8 @@ def _get_python_type(annotation: Any) -> type: def _collect_config_fields(config_class: type[BaseModel]) -> list[tuple[str, Any]]: """ - Collect all fields from a config class, flattening nested models. - Returns list of (name, field_info) tuples. - Raises ValueError on duplicate field names. + Collect all fields from a config class, flattening nested models. Returns list of + (name, field_info) tuples. Raises ValueError on duplicate field names. """ fields = [] seen_names: set[str] = set() From 6e7e52fb2690256fc8e61166cdbf7831193998e0 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 11 Dec 2025 15:27:27 -0500 Subject: [PATCH 07/17] add export command, nested reorg commands --- cli.py | 211 +------------------------------------- cli/__init__.py | 15 +++ cli/commands/__init__.py | 0 cli/commands/export.py | 122 ++++++++++++++++++++++ cli/commands/inference.py | 67 ++++++++++++ cli/commands/train.py | 111 ++++++++++++++++++++ 6 files changed, 316 insertions(+), 210 deletions(-) create mode 100644 cli/commands/__init__.py create mode 100644 cli/commands/export.py create mode 100644 cli/commands/inference.py create mode 100644 cli/commands/train.py diff --git a/cli.py b/cli.py index f960eee95f..05f7035b51 100644 --- a/cli.py +++ b/cli.py @@ -1,213 +1,4 @@ -import sys -import time -from pathlib import Path -from typing import Optional - -import typer - -from cli.config import Config, load_config -from cli.options import add_options_from_config - -app = typer.Typer( - help="Command-line interface for Unsloth training, chat, and export.", - context_settings={"help_option_names": ["-h", "--help"]}, -) - - -@app.command() -@add_options_from_config(Config) -def train( - config: Optional[Path] = typer.Option( - None, - "--config", - "-c", - help="Path to YAML/JSON config file. CLI flags override config values.", - ), - hf_token: Optional[str] = typer.Option( - None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show resolved config and exit without training.", - ), - config_overrides: dict = None, # Injected by add_options_from_config decorator -): - """Launch training using the existing Unsloth training backend.""" - try: - cfg = load_config(config) - except FileNotFoundError as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(code=2) - - # Apply CLI overrides - cfg.apply_overrides(**config_overrides) - - # Dry run: show resolved config and exit - if dry_run: - import yaml - data = cfg.model_dump() - data["training"]["output_dir"] = str(data["training"]["output_dir"]) - typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False)) - raise typer.Exit(code=0) - - # Validate required fields - if not cfg.model: - typer.echo("Error: provide --model or set model in --config", err=True) - raise typer.Exit(code=2) - - if not cfg.data.dataset and not cfg.data.local_dataset: - typer.echo( - "Error: provide --dataset or --local-dataset (or via --config)", err=True - ) - raise typer.Exit(code=2) - - # Lazy imports to avoid triggering Unsloth patches on --help - from backend.trainer import UnslothTrainer - from backend.model_config import ModelConfig - - trainer = UnslothTrainer() - - model_config = ModelConfig.from_ui_selection( - dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=False - ) - if not model_config: - typer.echo("Could not resolve model config", err=True) - raise typer.Exit(code=1) - - is_vision = model_config.is_vision - use_lora = cfg.training.training_type.lower() == "lora" - - if not trainer.load_model( - model_name=model_config.identifier, - max_seq_length=cfg.training.max_seq_length, - load_in_4bit=cfg.training.load_in_4bit if use_lora else False, - hf_token=hf_token, - ): - typer.echo("Model load failed", err=True) - raise typer.Exit(code=1) - - if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)): - typer.echo("Model preparation failed", err=True) - raise typer.Exit(code=1) - - ds = trainer.load_and_format_dataset( - dataset_source=cfg.data.dataset or "", - format_type=cfg.data.format_type, - local_datasets=cfg.data.local_dataset, - ) - if ds is None: - typer.echo("Dataset load failed", err=True) - raise typer.Exit(code=1) - - started = trainer.start_training(dataset=ds, **cfg.training_kwargs()) - - if not started: - typer.echo("Training failed to start", err=True) - raise typer.Exit(code=1) - - try: - while trainer.training_thread and trainer.training_thread.is_alive(): - time.sleep(1) - except KeyboardInterrupt: - typer.echo("Stopping training (Ctrl+C detected)...") - trainer.stop_training() - finally: - if trainer.training_thread: - trainer.training_thread.join() - - final = trainer.get_training_progress() - if getattr(final, "error", None): - typer.echo(f"Training error: {final.error}", err=True) - raise typer.Exit(code=1) - - -@app.command() -def inference( - model: str = typer.Argument(..., help="HF model id or local path."), - prompt: str = typer.Argument(..., help="Prompt to send to the model."), - hf_token: Optional[str] = typer.Option( - None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." - ), - temperature: float = typer.Option(0.7, "--temperature"), - top_p: float = typer.Option(0.9, "--top-p"), - top_k: int = typer.Option(40, "--top-k"), - max_new_tokens: int = typer.Option(256, "--max-new-tokens"), - repetition_penalty: float = typer.Option(1.1, "--repetition-penalty"), - system_prompt: str = typer.Option( - "", - "--system-prompt", - help="Optional system prompt to prepend.", - ), - max_seq_length: int = typer.Option(2048, "--max-seq-length"), - load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), -): - """Run a single inference using the specified model.""" - # Lazy imports to avoid triggering Unsloth patches on --help - from backend.model_config import ModelConfig - from backend.inference import get_inference_backend - - inference_backend = get_inference_backend() - model_config = ModelConfig.from_ui_selection( - dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False - ) - if not model_config: - typer.echo("Could not resolve model config", err=True) - raise typer.Exit(code=1) - - if not inference_backend.load_model( - config=model_config, - max_seq_length=max_seq_length, - load_in_4bit=load_in_4bit, - hf_token=hf_token, - ): - typer.echo("Model load failed", err=True) - raise typer.Exit(code=1) - - messages = [{"role": "user", "content": prompt}] - stream = inference_backend.generate_chat_response( - messages=messages, - system_prompt=system_prompt, - temperature=temperature, - top_p=top_p, - top_k=top_k, - max_new_tokens=max_new_tokens, - repetition_penalty=repetition_penalty, - ) - - typer.echo("Assistant:", nl=True) - previous = "" - for chunk in stream: - # Backend yields cumulative text; print only the delta - delta = chunk[len(previous):] - if delta: - sys.stdout.write(delta) - sys.stdout.flush() - previous = chunk - sys.stdout.write("\n") - sys.stdout.flush() - - -@app.command("list-checkpoints") -def list_checkpoints( - outputs_dir: Path = typer.Option( - Path("./outputs"), "--outputs-dir", help="Directory that holds training runs." - ), -): - """ - List checkpoints detected in the outputs directory. - """ - from backend.export import ExportBackend - - backend = ExportBackend() - checkpoints = backend.scan_checkpoints(outputs_dir=str(outputs_dir)) - if not checkpoints: - typer.echo("No checkpoints found.") - raise typer.Exit() - - for display, path in checkpoints: - typer.echo(f"{display}: {path}") - +from cli import app if __name__ == "__main__": app() diff --git a/cli/__init__.py b/cli/__init__.py index e69de29bb2..08f8f02813 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -0,0 +1,15 @@ +import typer + +from cli.commands.train import train +from cli.commands.inference import inference +from cli.commands.export import export, list_checkpoints + +app = typer.Typer( + help="Command-line interface for Unsloth training, inference, and export.", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +app.command()(train) +app.command()(inference) +app.command()(export) +app.command("list-checkpoints")(list_checkpoints) diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cli/commands/export.py b/cli/commands/export.py new file mode 100644 index 0000000000..6ed59639ec --- /dev/null +++ b/cli/commands/export.py @@ -0,0 +1,122 @@ +from pathlib import Path +from typing import Optional + +import typer + + +EXPORT_FORMATS = ["merged-16bit", "merged-4bit", "gguf", "lora"] +GGUF_QUANTS = ["q4_k_m", "q5_k_m", "q8_0", "f16"] + + +def list_checkpoints( + outputs_dir: Path = typer.Option( + Path("./outputs"), "--outputs-dir", help="Directory that holds training runs." + ), +): + """List checkpoints detected in the outputs directory.""" + from backend.export import ExportBackend + + backend = ExportBackend() + checkpoints = backend.scan_checkpoints(outputs_dir=str(outputs_dir)) + if not checkpoints: + typer.echo("No checkpoints found.") + raise typer.Exit() + + for display, path in checkpoints: + typer.echo(f"{display}: {path}") + + +def export( + checkpoint: Path = typer.Argument(..., help="Path to checkpoint directory."), + output_dir: Path = typer.Argument(..., help="Directory to save exported model."), + format: str = typer.Option( + "merged-16bit", + "--format", + "-f", + help=f"Export format: {', '.join(EXPORT_FORMATS)}", + ), + quantization: str = typer.Option( + "q4_k_m", + "--quantization", + "-q", + help=f"GGUF quantization method: {', '.join(GGUF_QUANTS)}", + ), + push_to_hub: bool = typer.Option( + False, "--push-to-hub", help="Push exported model to HuggingFace Hub." + ), + repo_id: Optional[str] = typer.Option( + None, "--repo-id", help="HuggingFace repo ID (username/model-name)." + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="HuggingFace token." + ), + private: bool = typer.Option( + False, "--private", help="Make the HuggingFace repo private." + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), +): + """Export a checkpoint to various formats (merged, GGUF, LoRA adapter).""" + if format not in EXPORT_FORMATS: + typer.echo(f"Error: Invalid format '{format}'. Choose from: {', '.join(EXPORT_FORMATS)}", err=True) + raise typer.Exit(code=2) + + if push_to_hub and not repo_id: + typer.echo("Error: --repo-id required when using --push-to-hub", err=True) + raise typer.Exit(code=2) + + from backend.export import ExportBackend + backend = ExportBackend() + + typer.echo(f"Loading checkpoint: {checkpoint}") + success, message = backend.load_checkpoint( + checkpoint_path=str(checkpoint), + max_seq_length=max_seq_length, + load_in_4bit=load_in_4bit, + ) + if not success: + typer.echo(f"Error: {message}", err=True) + raise typer.Exit(code=1) + typer.echo(message) + + typer.echo(f"Exporting as {format}...") + if format == "merged-16bit": + success, message = backend.export_merged_model( + save_directory=str(output_dir), + format_type="16-bit (FP16)", + push_to_hub=push_to_hub, + repo_id=repo_id, + hf_token=hf_token, + private=private, + ) + elif format == "merged-4bit": + success, message = backend.export_merged_model( + save_directory=str(output_dir), + format_type="4-bit (FP4)", + push_to_hub=push_to_hub, + repo_id=repo_id, + hf_token=hf_token, + private=private, + ) + elif format == "gguf": + success, message = backend.export_gguf( + save_directory=str(output_dir), + quantization_method=quantization.upper(), + push_to_hub=push_to_hub, + repo_id=repo_id, + hf_token=hf_token, + ) + elif format == "lora": + success, message = backend.export_lora_adapter( + save_directory=str(output_dir), + push_to_hub=push_to_hub, + repo_id=repo_id, + hf_token=hf_token, + private=private, + ) + + if not success: + typer.echo(f"Error: {message}", err=True) + raise typer.Exit(code=1) + + typer.echo(message) diff --git a/cli/commands/inference.py b/cli/commands/inference.py new file mode 100644 index 0000000000..bef0a2c4ee --- /dev/null +++ b/cli/commands/inference.py @@ -0,0 +1,67 @@ +import sys +from typing import Optional + +import typer + + +def inference( + model: str = typer.Argument(..., help="HF model id or local path."), + prompt: str = typer.Argument(..., help="Prompt to send to the model."), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), + temperature: float = typer.Option(0.7, "--temperature"), + top_p: float = typer.Option(0.9, "--top-p"), + top_k: int = typer.Option(40, "--top-k"), + max_new_tokens: int = typer.Option(256, "--max-new-tokens"), + repetition_penalty: float = typer.Option(1.1, "--repetition-penalty"), + system_prompt: str = typer.Option( + "", + "--system-prompt", + help="Optional system prompt to prepend.", + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), +): + """Run a single inference using the specified model.""" + from backend.model_config import ModelConfig + from backend.inference import get_inference_backend + + inference_backend = get_inference_backend() + model_config = ModelConfig.from_ui_selection( + dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False + ) + if not model_config: + typer.echo("Could not resolve model config", err=True) + raise typer.Exit(code=1) + + if not inference_backend.load_model( + config=model_config, + max_seq_length=max_seq_length, + load_in_4bit=load_in_4bit, + hf_token=hf_token, + ): + typer.echo("Model load failed", err=True) + raise typer.Exit(code=1) + + messages = [{"role": "user", "content": prompt}] + stream = inference_backend.generate_chat_response( + messages=messages, + system_prompt=system_prompt, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_new_tokens=max_new_tokens, + repetition_penalty=repetition_penalty, + ) + + typer.echo("Assistant:", nl=True) + previous = "" + for chunk in stream: + delta = chunk[len(previous):] + if delta: + sys.stdout.write(delta) + sys.stdout.flush() + previous = chunk + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/cli/commands/train.py b/cli/commands/train.py new file mode 100644 index 0000000000..dd357c55b9 --- /dev/null +++ b/cli/commands/train.py @@ -0,0 +1,111 @@ +import time +from pathlib import Path +from typing import Optional + +import typer + +from cli.config import Config, load_config +from cli.options import add_options_from_config + + +@add_options_from_config(Config) +def train( + config: Optional[Path] = typer.Option( + None, + "--config", + "-c", + help="Path to YAML/JSON config file. CLI flags override config values.", + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show resolved config and exit without training.", + ), + config_overrides: dict = None, +): + """Launch training using the existing Unsloth training backend.""" + try: + cfg = load_config(config) + except FileNotFoundError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=2) + + cfg.apply_overrides(**config_overrides) + + if dry_run: + import yaml + data = cfg.model_dump() + data["training"]["output_dir"] = str(data["training"]["output_dir"]) + typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False)) + raise typer.Exit(code=0) + + if not cfg.model: + typer.echo("Error: provide --model or set model in --config", err=True) + raise typer.Exit(code=2) + + if not cfg.data.dataset and not cfg.data.local_dataset: + typer.echo( + "Error: provide --dataset or --local-dataset (or via --config)", err=True + ) + raise typer.Exit(code=2) + + from backend.trainer import UnslothTrainer + from backend.model_config import ModelConfig + + trainer = UnslothTrainer() + + model_config = ModelConfig.from_ui_selection( + dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=False + ) + if not model_config: + typer.echo("Could not resolve model config", err=True) + raise typer.Exit(code=1) + + is_vision = model_config.is_vision + use_lora = cfg.training.training_type.lower() == "lora" + + if not trainer.load_model( + model_name=model_config.identifier, + max_seq_length=cfg.training.max_seq_length, + load_in_4bit=cfg.training.load_in_4bit if use_lora else False, + hf_token=hf_token, + ): + typer.echo("Model load failed", err=True) + raise typer.Exit(code=1) + + if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)): + typer.echo("Model preparation failed", err=True) + raise typer.Exit(code=1) + + ds = trainer.load_and_format_dataset( + dataset_source=cfg.data.dataset or "", + format_type=cfg.data.format_type, + local_datasets=cfg.data.local_dataset, + ) + if ds is None: + typer.echo("Dataset load failed", err=True) + raise typer.Exit(code=1) + + started = trainer.start_training(dataset=ds, **cfg.training_kwargs()) + + if not started: + typer.echo("Training failed to start", err=True) + raise typer.Exit(code=1) + + try: + while trainer.training_thread and trainer.training_thread.is_alive(): + time.sleep(1) + except KeyboardInterrupt: + typer.echo("Stopping training (Ctrl+C detected)...") + trainer.stop_training() + finally: + if trainer.training_thread: + trainer.training_thread.join() + + final = trainer.get_training_progress() + if getattr(final, "error", None): + typer.echo(f"Training error: {final.error}", err=True) + raise typer.Exit(code=1) From e79b1c7832e126423757a3698dd58c729a49d0f4 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Fri, 12 Dec 2025 10:16:53 -0500 Subject: [PATCH 08/17] review comments, tests, etc. --- cli/commands/train.py | 26 ++++++++++++++++++++++++-- cli/config.py | 9 +++++---- cli/options.py | 4 ++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/cli/commands/train.py b/cli/commands/train.py index dd357c55b9..efc034d6ea 100644 --- a/cli/commands/train.py +++ b/cli/commands/train.py @@ -19,6 +19,9 @@ def train( hf_token: Optional[str] = typer.Option( None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed." ), + wandb_token: Optional[str] = typer.Option( + None, "--wandb-token", envvar="WANDB_API_KEY", help="Weights & Biases API key." + ), dry_run: bool = typer.Option( False, "--dry-run", @@ -35,6 +38,10 @@ def train( cfg.apply_overrides(**config_overrides) + # CLI/env tokens take precedence over config + hf_token = hf_token or cfg.logging.hf_token + wandb_token = wandb_token or cfg.logging.wandb_token + if dry_run: import yaml data = cfg.model_dump() @@ -52,13 +59,18 @@ def train( ) raise typer.Exit(code=2) + from pathlib import Path as PathlibPath from backend.trainer import UnslothTrainer from backend.model_config import ModelConfig trainer = UnslothTrainer() + # Check if the model path is a LoRA adapter (has adapter_config.json) + model_path = PathlibPath(cfg.model) if cfg.model else None + model_is_lora = model_path and model_path.is_dir() and (model_path / "adapter_config.json").exists() + model_config = ModelConfig.from_ui_selection( - dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=False + dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=model_is_lora ) if not model_config: typer.echo("Could not resolve model config", err=True) @@ -67,6 +79,14 @@ def train( is_vision = model_config.is_vision use_lora = cfg.training.training_type.lower() == "lora" + if model_is_lora and not use_lora: + typer.echo( + "Error: Cannot do full finetuning on a LoRA adapter. " + "Use --training-type lora or provide a base model.", + err=True, + ) + raise typer.Exit(code=2) + if not trainer.load_model( model_name=model_config.identifier, max_seq_length=cfg.training.max_seq_length, @@ -89,7 +109,9 @@ def train( typer.echo("Dataset load failed", err=True) raise typer.Exit(code=1) - started = trainer.start_training(dataset=ds, **cfg.training_kwargs()) + training_kwargs = cfg.training_kwargs() + training_kwargs["wandb_token"] = wandb_token # CLI/env takes precedence + started = trainer.start_training(dataset=ds, **training_kwargs) if not started: typer.echo("Training failed to start", err=True) diff --git a/cli/config.py b/cli/config.py index d80f3a2f63..aa402f32ef 100644 --- a/cli/config.py +++ b/cli/config.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, List +from typing import Literal, Optional, List import yaml from pydantic import BaseModel, Field @@ -8,11 +8,11 @@ from pydantic import BaseModel, Field class DataConfig(BaseModel): dataset: Optional[str] = None local_dataset: Optional[List[str]] = None - format_type: str = "auto" + format_type: Literal["auto", "alpaca", "chatml", "sharegpt"] = "auto" class TrainingConfig(BaseModel): - training_type: str = "lora" + training_type: Literal["lora", "full"] = "lora" max_seq_length: int = 2048 load_in_4bit: bool = True output_dir: Path = Path("./outputs") @@ -27,7 +27,7 @@ class TrainingConfig(BaseModel): random_seed: int = 3407 packing: bool = False train_on_completions: bool = False - gradient_checkpointing: bool = True + gradient_checkpointing: Literal["unsloth", "true", "none"] = "unsloth" class LoraConfig(BaseModel): @@ -53,6 +53,7 @@ class LoggingConfig(BaseModel): wandb_token: Optional[str] = None enable_tensorboard: bool = False tensorboard_dir: str = "runs" + hf_token: Optional[str] = None class Config(BaseModel): diff --git a/cli/options.py b/cli/options.py index e9269a6cd1..c8a08e59f2 100644 --- a/cli/options.py +++ b/cli/options.py @@ -82,11 +82,15 @@ def add_options_from_config(config_class: type[BaseModel]) -> Callable: def decorator(func: Callable) -> Callable: sig = inspect.signature(func) original_params = list(sig.parameters.values()) + original_param_names = {p.name for p in original_params} # Build new parameters: config fields first, then original params new_params = [] for field_name, field_info in fields: + # Skip fields already defined in function signature (e.g., with envvar) + if field_name in original_param_names: + continue annotation = field_info.annotation if _is_list_type(annotation): continue From 1a929732f65c8fd97c2d257cb140c97cf458474c Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Sat, 13 Dec 2025 08:57:08 -0500 Subject: [PATCH 09/17] bugfix --- cli/options.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/options.py b/cli/options.py index c8a08e59f2..dbc66a9931 100644 --- a/cli/options.py +++ b/cli/options.py @@ -135,7 +135,9 @@ def add_options_from_config(config_class: type[BaseModel]) -> Callable: if key in field_names: if kwargs[key] is not None: config_overrides[key] = kwargs[key] - del kwargs[key] + # Only delete if not an explicitly declared parameter + if key not in original_param_names: + del kwargs[key] kwargs["config_overrides"] = config_overrides return func(*args, **kwargs) From 64a13cd4b5a806010e083aee992121dd86ff2723 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Mon, 15 Dec 2025 18:28:07 -0500 Subject: [PATCH 10/17] vision config -> lora --- cli/config.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cli/config.py b/cli/config.py index aa402f32ef..42169019ed 100644 --- a/cli/config.py +++ b/cli/config.py @@ -38,9 +38,6 @@ class LoraConfig(BaseModel): vision_all_linear: bool = False use_rslora: bool = False use_loftq: bool = False - - -class VisionConfig(BaseModel): finetune_vision_layers: bool = True finetune_language_layers: bool = True finetune_attention_modules: bool = True @@ -61,7 +58,6 @@ class Config(BaseModel): data: DataConfig = Field(default_factory=DataConfig) training: TrainingConfig = Field(default_factory=TrainingConfig) lora: LoraConfig = Field(default_factory=LoraConfig) - vision: VisionConfig = Field(default_factory=VisionConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) def apply_overrides(self, **kwargs): @@ -72,7 +68,7 @@ class Config(BaseModel): if hasattr(self, key): setattr(self, key, value) else: - for section in (self.data, self.training, self.lora, self.vision, self.logging): + for section in (self.data, self.training, self.lora, self.logging): if hasattr(section, key): setattr(section, key, value) break @@ -87,10 +83,10 @@ class Config(BaseModel): return { "use_lora": use_lora, - "finetune_vision_layers": self.vision.finetune_vision_layers, - "finetune_language_layers": self.vision.finetune_language_layers, - "finetune_attention_modules": self.vision.finetune_attention_modules, - "finetune_mlp_modules": self.vision.finetune_mlp_modules, + "finetune_vision_layers": self.lora.finetune_vision_layers, + "finetune_language_layers": self.lora.finetune_language_layers, + "finetune_attention_modules": self.lora.finetune_attention_modules, + "finetune_mlp_modules": self.lora.finetune_mlp_modules, "target_modules": target_modules, "lora_r": self.lora.lora_r, "lora_alpha": self.lora.lora_alpha, From e012936d75b19179f08ec54c01c2775dcb9f506d Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Mon, 15 Dec 2025 18:46:21 -0500 Subject: [PATCH 11/17] nits --- cli/commands/train.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/cli/commands/train.py b/cli/commands/train.py index efc034d6ea..ec9b36588d 100644 --- a/cli/commands/train.py +++ b/cli/commands/train.py @@ -59,24 +59,9 @@ def train( ) raise typer.Exit(code=2) - from pathlib import Path as PathlibPath - from backend.trainer import UnslothTrainer - from backend.model_config import ModelConfig - - trainer = UnslothTrainer() - # Check if the model path is a LoRA adapter (has adapter_config.json) - model_path = PathlibPath(cfg.model) if cfg.model else None + model_path = Path(cfg.model) if cfg.model else None model_is_lora = model_path and model_path.is_dir() and (model_path / "adapter_config.json").exists() - - model_config = ModelConfig.from_ui_selection( - dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=model_is_lora - ) - if not model_config: - typer.echo("Could not resolve model config", err=True) - raise typer.Exit(code=1) - - is_vision = model_config.is_vision use_lora = cfg.training.training_type.lower() == "lora" if model_is_lora and not use_lora: @@ -87,6 +72,20 @@ def train( ) raise typer.Exit(code=2) + from backend.trainer import UnslothTrainer + from backend.model_config import ModelConfig + + trainer = UnslothTrainer() + + model_config = ModelConfig.from_ui_selection( + dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=model_is_lora + ) + if not model_config: + typer.echo("Could not resolve model config", err=True) + raise typer.Exit(code=1) + + is_vision = model_config.is_vision + if not trainer.load_model( model_name=model_config.identifier, max_seq_length=cfg.training.max_seq_length, From 7833191626f8dc251bdca43a5f98df3dc2cf85e7 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Wed, 17 Dec 2025 15:22:42 -0500 Subject: [PATCH 12/17] review comments --- cli/commands/train.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/commands/train.py b/cli/commands/train.py index ec9b36588d..798f6942ee 100644 --- a/cli/commands/train.py +++ b/cli/commands/train.py @@ -39,6 +39,12 @@ def train( cfg.apply_overrides(**config_overrides) # CLI/env tokens take precedence over config + # Handle case where typer.Option isn't resolved (decorator interaction) + from typer.models import OptionInfo + if isinstance(hf_token, OptionInfo): + hf_token = None + if isinstance(wandb_token, OptionInfo): + wandb_token = None hf_token = hf_token or cfg.logging.hf_token wandb_token = wandb_token or cfg.logging.wandb_token From 44104fa83d05888eb8ec5a570dbd89ae2cfa0f37 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Wed, 17 Dec 2025 15:28:03 -0500 Subject: [PATCH 13/17] review comments --- cli/commands/train.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/cli/commands/train.py b/cli/commands/train.py index 798f6942ee..900a345536 100644 --- a/cli/commands/train.py +++ b/cli/commands/train.py @@ -79,21 +79,12 @@ def train( raise typer.Exit(code=2) from backend.trainer import UnslothTrainer - from backend.model_config import ModelConfig trainer = UnslothTrainer() - model_config = ModelConfig.from_ui_selection( - dropdown_value=cfg.model, search_value=None, hf_token=hf_token, is_lora=model_is_lora - ) - if not model_config: - typer.echo("Could not resolve model config", err=True) - raise typer.Exit(code=1) - - is_vision = model_config.is_vision - + # Load model (trainer.is_vlm is set after this) if not trainer.load_model( - model_name=model_config.identifier, + model_name=cfg.model, max_seq_length=cfg.training.max_seq_length, load_in_4bit=cfg.training.load_in_4bit if use_lora else False, hf_token=hf_token, @@ -101,6 +92,8 @@ def train( typer.echo("Model load failed", err=True) raise typer.Exit(code=1) + is_vision = trainer.is_vlm + if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)): typer.echo("Model preparation failed", err=True) raise typer.Exit(code=1) From f47ebfd237ee183390313edbe1325388866dec72 Mon Sep 17 00:00:00 2001 From: Dan Saunders Date: Thu, 1 Jan 2026 13:50:22 -0500 Subject: [PATCH 14/17] CLI command for UI --- cli/__init__.py | 2 ++ cli/commands/ui.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 cli/commands/ui.py diff --git a/cli/__init__.py b/cli/__init__.py index 08f8f02813..58c949b28b 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -3,6 +3,7 @@ import typer from cli.commands.train import train from cli.commands.inference import inference from cli.commands.export import export, list_checkpoints +from cli.commands.ui import ui app = typer.Typer( help="Command-line interface for Unsloth training, inference, and export.", @@ -13,3 +14,4 @@ app.command()(train) app.command()(inference) app.command()(export) app.command("list-checkpoints")(list_checkpoints) +app.command()(ui) diff --git a/cli/commands/ui.py b/cli/commands/ui.py new file mode 100644 index 0000000000..5a8459600c --- /dev/null +++ b/cli/commands/ui.py @@ -0,0 +1,19 @@ +import typer + + +def ui( + port: int = typer.Option(8000, "--port", "-p", help="Port to run the UI server on."), + host: str = typer.Option("0.0.0.0", "--host", "-H", help="Host address to bind to."), + share: bool = typer.Option(False, "--share", "-s", help="Create a public Gradio share link."), +): + """Launch the Unsloth web UI for training, inference, and export.""" + from app import demo, script_dir + + typer.echo(f"Starting Unsloth UI on http://{host}:{port}") + + demo.launch( + share=share, + server_port=port, + server_name=host, + favicon_path=f"{script_dir}/assets/favicon-32x32.png", + ) From 32e72a12ae592b991a6bc9a2ab92023aa6561a38 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 20 Jan 2026 07:27:05 +0000 Subject: [PATCH 15/17] add studio command line argument to start unsloth studio UI --- cli/__init__.py | 2 ++ cli/commands/studio.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 cli/commands/studio.py diff --git a/cli/__init__.py b/cli/__init__.py index 58c949b28b..7a0918d16b 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -4,6 +4,7 @@ from cli.commands.train import train from cli.commands.inference import inference from cli.commands.export import export, list_checkpoints from cli.commands.ui import ui +from cli.commands.studio import studio app = typer.Typer( help="Command-line interface for Unsloth training, inference, and export.", @@ -15,3 +16,4 @@ app.command()(inference) app.command()(export) app.command("list-checkpoints")(list_checkpoints) app.command()(ui) +app.command()(studio) diff --git a/cli/commands/studio.py b/cli/commands/studio.py new file mode 100644 index 0000000000..6e20a19436 --- /dev/null +++ b/cli/commands/studio.py @@ -0,0 +1,19 @@ +import typer + + +def studio( + port: int = typer.Option(8000, "--port", "-p", help="Port to run the UI server on."), + host: str = typer.Option("0.0.0.0", "--host", "-H", help="Host address to bind to."), + share: bool = typer.Option(False, "--share", "-s", help="Create a public Gradio share link."), +): + """Launch the Unsloth web UI for training, inference, and export.""" + from app import demo, script_dir + + typer.echo(f"Starting Unsloth UI on http://{host}:{port}") + + demo.launch( + share=share, + server_port=port, + server_name=host, + favicon_path=f"{script_dir}/assets/favicon-32x32.png", + ) From 02982ceeba21efd75117292114e39165e70205f3 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 20 Jan 2026 07:36:57 +0000 Subject: [PATCH 16/17] set create public gradio share link to true --- cli/commands/studio.py | 2 +- cli/commands/ui.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/commands/studio.py b/cli/commands/studio.py index 6e20a19436..4287c756a8 100644 --- a/cli/commands/studio.py +++ b/cli/commands/studio.py @@ -4,7 +4,7 @@ import typer def studio( port: int = typer.Option(8000, "--port", "-p", help="Port to run the UI server on."), host: str = typer.Option("0.0.0.0", "--host", "-H", help="Host address to bind to."), - share: bool = typer.Option(False, "--share", "-s", help="Create a public Gradio share link."), + share: bool = typer.Option(True, "--share", "-s", help="Create a public Gradio share link."), ): """Launch the Unsloth web UI for training, inference, and export.""" from app import demo, script_dir diff --git a/cli/commands/ui.py b/cli/commands/ui.py index 5a8459600c..cbd8194b52 100644 --- a/cli/commands/ui.py +++ b/cli/commands/ui.py @@ -4,7 +4,7 @@ import typer def ui( port: int = typer.Option(8000, "--port", "-p", help="Port to run the UI server on."), host: str = typer.Option("0.0.0.0", "--host", "-H", help="Host address to bind to."), - share: bool = typer.Option(False, "--share", "-s", help="Create a public Gradio share link."), + share: bool = typer.Option(True, "--share", "-s", help="Create a public Gradio share link."), ): """Launch the Unsloth web UI for training, inference, and export.""" from app import demo, script_dir From 30c75e6d41ec5e817c0020e6193fc42eab2cf98d Mon Sep 17 00:00:00 2001 From: sshah229 Date: Wed, 21 Jan 2026 02:40:25 -0700 Subject: [PATCH 17/17] fixed parameters (finetune language, vision, attention layers, and mlp_modules) not updating --- cli/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/config.py b/cli/config.py index 42169019ed..b6bc8dc999 100644 --- a/cli/config.py +++ b/cli/config.py @@ -77,9 +77,11 @@ class Config(BaseModel): """Return kwargs for trainer.prepare_model_for_training().""" # Determine target modules based on model type if use_lora and is_vision: - target_modules = "all-linear" if self.lora.vision_all_linear else [] + # Vision models expect a string (e.g., "all-linear"); fall back to None to use trainer defaults + target_modules = "all-linear" if self.lora.vision_all_linear else None else: - target_modules = [m.strip() for m in self.lora.target_modules.split(",") if m.strip()] + parsed = [m.strip() for m in str(self.lora.target_modules).split(",") if m and m.strip()] + target_modules = parsed or None return { "use_lora": use_lora,