mirror of https://github.com/razor-ai/soup.git
160 lines
4.9 KiB
Python
160 lines
4.9 KiB
Python
"""soup merge — merge LoRA adapter with base model into a full model."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
|
|
console = Console()
|
|
|
|
|
|
def merge(
|
|
adapter: str = typer.Option(
|
|
...,
|
|
"--adapter",
|
|
"-a",
|
|
help="Path to the LoRA adapter directory",
|
|
),
|
|
base: Optional[str] = typer.Option(
|
|
None,
|
|
"--base",
|
|
"-b",
|
|
help="Base model ID. Auto-detected from adapter_config.json if not set.",
|
|
),
|
|
output: str = typer.Option(
|
|
"./merged",
|
|
"--output",
|
|
"-o",
|
|
help="Output directory for the merged model",
|
|
),
|
|
dtype: str = typer.Option(
|
|
"float16",
|
|
"--dtype",
|
|
help="Data type for the merged model: float16, bfloat16, float32",
|
|
),
|
|
):
|
|
"""Merge a LoRA adapter with its base model into a full model."""
|
|
adapter_path = Path(adapter)
|
|
|
|
# --- Validate adapter ---
|
|
if not adapter_path.exists():
|
|
console.print(f"[red]Adapter path not found: {adapter_path}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
adapter_config_path = adapter_path / "adapter_config.json"
|
|
if not adapter_config_path.exists():
|
|
console.print(
|
|
f"[red]Not a LoRA adapter: {adapter_path}[/]\n"
|
|
"Expected adapter_config.json in the directory."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# --- Resolve base model ---
|
|
if not base:
|
|
base = _detect_base_model(adapter_config_path)
|
|
if not base:
|
|
console.print(
|
|
"[red]Cannot detect base model from adapter_config.json.[/]\n"
|
|
"Please specify with [bold]--base[/] flag."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# --- Validate dtype ---
|
|
valid_dtypes = ("float16", "bfloat16", "float32")
|
|
if dtype not in valid_dtypes:
|
|
console.print(f"[red]Invalid dtype: {dtype}. Must be one of: {', '.join(valid_dtypes)}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
output_path = Path(output)
|
|
|
|
console.print(
|
|
Panel(
|
|
f"Adapter: [bold]{adapter_path}[/]\n"
|
|
f"Base: [bold]{base}[/]\n"
|
|
f"Output: [bold]{output_path}[/]\n"
|
|
f"Dtype: [bold]{dtype}[/]",
|
|
title="Merge Plan",
|
|
)
|
|
)
|
|
|
|
# --- Merge ---
|
|
try:
|
|
import torch
|
|
from peft import PeftModel
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
dtype_map = {
|
|
"float16": torch.float16,
|
|
"bfloat16": torch.bfloat16,
|
|
"float32": torch.float32,
|
|
}
|
|
model_dtype = dtype_map[dtype]
|
|
|
|
console.print(f"[dim]Loading base model: {base}...[/]")
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
base,
|
|
dtype=model_dtype,
|
|
trust_remote_code=True,
|
|
device_map="cpu",
|
|
)
|
|
|
|
console.print(f"[dim]Loading LoRA adapter: {adapter_path}...[/]")
|
|
model = PeftModel.from_pretrained(model, str(adapter_path))
|
|
|
|
console.print("[dim]Merging weights...[/]")
|
|
model = model.merge_and_unload()
|
|
|
|
console.print(f"[dim]Saving merged model to {output_path}...[/]")
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
model.save_pretrained(str(output_path))
|
|
|
|
console.print("[dim]Saving tokenizer...[/]")
|
|
tokenizer = AutoTokenizer.from_pretrained(str(adapter_path), trust_remote_code=True)
|
|
tokenizer.save_pretrained(str(output_path))
|
|
|
|
except ImportError as exc:
|
|
console.print(f"[red]Missing dependency: {exc}[/]")
|
|
console.print("Run: [bold]pip install torch transformers peft[/]")
|
|
raise typer.Exit(1)
|
|
except Exception as exc:
|
|
console.print(f"[red]Merge failed: {exc}[/]")
|
|
raise typer.Exit(1)
|
|
|
|
# Calculate output size
|
|
total_size = sum(f.stat().st_size for f in output_path.rglob("*") if f.is_file())
|
|
size_str = _format_size(total_size)
|
|
|
|
console.print(
|
|
Panel(
|
|
f"Output: [bold]{output_path}[/]\n"
|
|
f"Size: [bold]{size_str}[/]\n\n"
|
|
f"Next steps:\n"
|
|
f" [bold]soup chat --model {output_path}[/]\n"
|
|
f" [bold]soup push --model {output_path} --repo user/model[/]\n"
|
|
f" [bold]soup export --model {output_path} --format gguf[/]",
|
|
title="[bold green]Merge Complete![/]",
|
|
)
|
|
)
|
|
|
|
|
|
def _detect_base_model(adapter_config_path: Path) -> Optional[str]:
|
|
"""Read base_model_name_or_path from adapter_config.json."""
|
|
try:
|
|
with open(adapter_config_path, encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
return config.get("base_model_name_or_path")
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
def _format_size(size_bytes: int) -> str:
|
|
"""Format bytes into human-readable string."""
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes:.1f} {unit}"
|
|
size_bytes /= 1024
|
|
return f"{size_bytes:.1f} TB"
|