@model.command()
@click.argument("hf_repo")
@click.option("--engine", default=None, help="Engine to convert for.")
@click.option(
"--quantize",
default=None,
help="GGUF quantization type (e.g., q4_k_m, q8_0).",
)
@click.option(
"-q",
"--mlx-4bit",
is_flag=True,
default=False,
help="Enable 4-bit quantization for MLX.",
)
@click.option(
"--output",
default=None,
help="Output directory for converted artifact.",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Replace existing output only after conversion succeeds.",
)
def convert(
hf_repo: str,
engine: str | None,
quantize: str | None,
mlx_4bit: bool,
output: str | None,
force: bool,
) -> None:
"""Convert a Hugging Face model to a local engine format."""
console = Console()
config = load_config()
# Resolve engine and reject combinations that would otherwise be silently
# ignored or produce an artifact the selected runtime cannot consume.
engine = (engine or config.engine.default or "mlx").lower()
if engine in ("vllm", "sglang"):
console.print(
f"[green]No conversion needed for {engine}.[/green] It consumes "
f"Hugging Face repositories directly.\n"
f"[cyan]Start with:[/cyan] jarvis host {shlex.quote(hf_repo)} "
f"--backend {engine}"
)
return
if engine not in ("mlx", "llamacpp", "ollama"):
console.print(f"[red]Unsupported conversion engine:[/red] {engine}")
raise SystemExit(1)
if engine == "mlx" and quantize:
console.print("[red]--quantize is only valid for GGUF conversion.[/red]")
raise SystemExit(1)
if engine != "mlx" and mlx_4bit:
console.print("[red]--mlx-4bit is only valid with --engine mlx.[/red]")
raise SystemExit(1)
if quantize and not re.fullmatch(r"[A-Za-z0-9_]+", quantize):
console.print("[red]Invalid GGUF quantization name.[/red]")
raise SystemExit(1)
quantize = quantize.lower() if quantize else None
# Resolve output path
if output is None:
slug = _slug_from_repo(hf_repo, engine, quantize, mlx_4bit)
output = str(DEFAULT_CONFIG_DIR / "models" / slug)
# Keep the final path absolute without resolving a user-supplied symlink:
# --force must replace the link itself, never its target.
output_path = Path(os.path.abspath(Path(output).expanduser()))
# Check existing output. --force does not remove it yet: conversion happens
# in a sibling staging directory, so failures leave the old artifact intact.
if os.path.lexists(output_path):
# A symlink is always an existing output object, even when it points to
# an empty directory. Never follow it to decide whether overwriting is
# safe; --force replaces the link itself after conversion succeeds.
if output_path.is_symlink() and not force:
console.print(
f"[red]Output path exists as a symlink:[/red] {output_path}\n"
"Use [cyan]--force[/cyan] to overwrite."
)
sys.exit(1)
if output_path.is_dir() and not output_path.is_symlink():
contents = os.listdir(output_path)
if contents and not force:
console.print(
"[red]Output directory exists and is non-empty:[/red] "
f"{output_path}\n"
"Use [cyan]--force[/cyan] to overwrite."
)
sys.exit(1)
elif not force:
console.print(
f"[red]Output path exists as a file:[/red] {output_path}\n"
"Use [cyan]--force[/cyan] to overwrite."
)
sys.exit(1)
output_path.parent.mkdir(parents=True, exist_ok=True)
temp_root = Path(
tempfile.mkdtemp(prefix=f".{output_path.name}.convert-", dir=output_path.parent)
)
staged_output = temp_root / "artifact"
artifact_relative: Path | None = None
ollama_name: str | None = None
try:
if engine == "mlx":
if not _convert_mlx(hf_repo, str(staged_output), mlx_4bit, console):
raise SystemExit(1)
else:
gguf_path = _convert_gguf(
hf_repo,
str(staged_output),
quantize,
console,
config,
)
if gguf_path is None:
raise SystemExit(1)
artifact_relative = Path(gguf_path).relative_to(staged_output)
if engine == "ollama":
ollama_name = _slug_from_repo(
hf_repo.rsplit("/", 1)[-1], "", None, False
).lower()
if not _import_ollama(
gguf_path,
staged_output,
ollama_name,
console,
):
raise SystemExit(1)
_publish_output(staged_output, output_path)
finally:
if temp_root.exists():
shutil.rmtree(temp_root)
if engine == "mlx":
artifact = output_path
hint = f"jarvis host {shlex.quote(str(artifact))} --backend mlx"
title = "MLX conversion complete"
else:
assert artifact_relative is not None
artifact = output_path / artifact_relative
if engine == "ollama":
assert ollama_name is not None
hint = f"jarvis chat --engine ollama --model {shlex.quote(ollama_name)}"
title = "GGUF conversion and Ollama import complete"
else:
hint = f"jarvis host {shlex.quote(str(artifact))} --backend llamacpp"
title = "GGUF conversion complete"
console.print(
Panel(
f"[bold]Artifact:[/bold] {artifact}\n\n[cyan]Start with:[/cyan]\n {hint}",
title=title,
border_style="green",
)
)