soup/soup_cli/commands/adapters.py

212 lines
6.7 KiB
Python

"""soup adapters — LoRA adapter management (list, info, compare)."""
import json
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
app = typer.Typer(no_args_is_help=True)
def _find_adapters(directory: Path, max_depth: int = 6) -> list[Path]:
"""Recursively find directories containing adapter_config.json.
Limited to max_depth levels and 200 results to prevent runaway scans.
"""
adapters = []
for config_file in directory.rglob("adapter_config.json"):
try:
depth = len(config_file.relative_to(directory).parts)
except ValueError:
continue
if depth <= max_depth:
adapters.append(config_file.parent)
if len(adapters) >= 200:
break
return sorted(adapters)
def _read_adapter_config(adapter_path: Path) -> dict:
"""Read adapter_config.json from an adapter directory."""
config_file = adapter_path / "adapter_config.json"
with open(config_file, encoding="utf-8") as fh:
return json.load(fh)
def _get_adapter_size(adapter_path: Path) -> str:
"""Get total size of adapter files on disk."""
total_bytes = 0
for file_path in adapter_path.iterdir():
if file_path.is_file():
total_bytes += file_path.stat().st_size
if total_bytes < 1024:
return f"{total_bytes} B"
elif total_bytes < 1024 * 1024:
return f"{total_bytes / 1024:.1f} KB"
elif total_bytes < 1024 * 1024 * 1024:
return f"{total_bytes / (1024 * 1024):.1f} MB"
else:
return f"{total_bytes / (1024 * 1024 * 1024):.2f} GB"
@app.command(name="list")
def list_adapters(
directory: str = typer.Argument(
".", help="Directory to scan for adapters (recursive)"
),
):
"""Scan a directory for LoRA adapters and list them."""
dir_path = Path(directory).resolve()
if not dir_path.exists():
console.print(f"[red]Directory not found: {directory}[/]")
raise typer.Exit(1)
adapters = _find_adapters(dir_path)
if not adapters:
console.print("[dim]No adapters found in this directory.[/]")
return
table = Table(title=f"Adapters in {dir_path}")
table.add_column("Path", style="bold")
table.add_column("Base Model")
table.add_column("LoRA r")
table.add_column("Type")
table.add_column("Size")
for adapter_path in adapters:
try:
config = _read_adapter_config(adapter_path)
base = config.get("base_model_name_or_path", "unknown")
lora_r = str(config.get("r", "?"))
peft_type = config.get("peft_type", "?")
size = _get_adapter_size(adapter_path)
# Shorten path relative to scan directory
try:
rel_path = adapter_path.relative_to(dir_path)
except ValueError:
rel_path = adapter_path
table.add_row(str(rel_path), base, lora_r, peft_type, size)
except (json.JSONDecodeError, OSError):
table.add_row(str(adapter_path), "[red]error[/]", "-", "-", "-")
console.print(table)
console.print(f"\n[dim]Found {len(adapters)} adapter(s).[/]")
@app.command()
def info(
adapter: str = typer.Argument(..., help="Path to adapter directory"),
):
"""Show detailed metadata for a LoRA adapter."""
adapter_path = Path(adapter).resolve()
config_file = adapter_path / "adapter_config.json"
if not adapter_path.exists():
console.print(f"[red]Adapter not found: {adapter}[/]")
raise typer.Exit(1)
if not config_file.exists():
console.print(f"[red]No adapter_config.json in: {adapter}[/]")
raise typer.Exit(1)
config = _read_adapter_config(adapter_path)
size = _get_adapter_size(adapter_path)
base_model = config.get("base_model_name_or_path", "unknown")
lora_r = config.get("r", "?")
lora_alpha = config.get("lora_alpha", "?")
lora_dropout = config.get("lora_dropout", "?")
peft_type = config.get("peft_type", "?")
task_type = config.get("task_type", "?")
target_modules = config.get("target_modules", [])
if isinstance(target_modules, list):
modules_str = ", ".join(target_modules)
else:
modules_str = str(target_modules)
info_text = (
f"Base model: [bold]{base_model}[/]\n"
f"PEFT type: [bold]{peft_type}[/]\n"
f"Task: [bold]{task_type}[/]\n"
f"LoRA rank: [bold]{lora_r}[/], alpha: [bold]{lora_alpha}[/], "
f"dropout: [bold]{lora_dropout}[/]\n"
f"Targets: [bold]{modules_str}[/]\n"
f"Size on disk: [bold]{size}[/]"
)
console.print(Panel(info_text, title=f"Adapter Info -- {adapter_path.name}"))
@app.command()
def compare(
adapter1: str = typer.Argument(..., help="Path to first adapter"),
adapter2: str = typer.Argument(..., help="Path to second adapter"),
):
"""Compare two LoRA adapters side-by-side."""
path1 = Path(adapter1).resolve()
path2 = Path(adapter2).resolve()
for label, path in [("Adapter 1", path1), ("Adapter 2", path2)]:
config_file = path / "adapter_config.json"
if not path.exists():
console.print(f"[red]{label} not found: {path}[/]")
raise typer.Exit(1)
if not config_file.exists():
console.print(f"[red]{label} has no adapter_config.json: {path}[/]")
raise typer.Exit(1)
config1 = _read_adapter_config(path1)
config2 = _read_adapter_config(path2)
table = Table(title="Adapter Comparison")
table.add_column("Field", style="bold")
table.add_column(path1.name, justify="center")
table.add_column(path2.name, justify="center")
# Fields to compare
fields = [
("Base model", "base_model_name_or_path"),
("PEFT type", "peft_type"),
("Task type", "task_type"),
("LoRA rank (r)", "r"),
("LoRA alpha", "lora_alpha"),
("LoRA dropout", "lora_dropout"),
("Target modules", "target_modules"),
]
for label, key in fields:
val1 = config1.get(key, "-")
val2 = config2.get(key, "-")
# Format lists
if isinstance(val1, list):
val1 = ", ".join(str(item) for item in val1)
if isinstance(val2, list):
val2 = ", ".join(str(item) for item in val2)
val1_str = str(val1)
val2_str = str(val2)
# Highlight differences
if val1_str != val2_str:
val1_str = f"[yellow]{val1_str}[/]"
val2_str = f"[yellow]{val2_str}[/]"
table.add_row(label, val1_str, val2_str)
# Add size comparison
size1 = _get_adapter_size(path1)
size2 = _get_adapter_size(path2)
table.add_row("Size on disk", size1, size2)
console.print(table)