mirror of https://github.com/razor-ai/soup.git
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""v0.46.0 Part A — On-Device Deploy Autopilot.
|
|
|
|
Closed allowlist of deploy-target profiles. Each profile maps a hardware /
|
|
runtime target (mac-m3, rtx-4090-24gb, iphone-16, ollama-local, ...) to a
|
|
PEFT + quantisation + speculative-decoding combo plus an output recipe and
|
|
a deploy shell script.
|
|
|
|
Live wiring into the v0.26.0 Quant-Lobotomy Checker (so the autopilot
|
|
actually *measures* OK/MINOR/MAJOR before picking a quant) is deferred to
|
|
v0.46.1; this release ships the schema + the canonical combo table + the
|
|
recipe-yaml / deploy-script writers so users have a reproducible artifact
|
|
the moment they run ``soup deploy autopilot --target mac-m3``.
|
|
|
|
The catalog is frozen at import time (``MappingProxyType``); name lookup is
|
|
case-insensitive over a strict kebab-case regex (matches v0.45.0 Part A
|
|
``register_plugin`` policy).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Mapping, Optional, Tuple
|
|
|
|
from soup_cli.utils.paths import is_under_cwd
|
|
|
|
_PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,31}$")
|
|
_MAX_BASE_LEN = 200
|
|
_MAX_OUTPUT_PATH_LEN = 4096
|
|
|
|
# Allowlist for the quant field — matches v0.38.0 / v0.40.5 Quant Menu names
|
|
# plus the lightweight ``none`` / ``4bit`` / ``8bit`` legacy values. We keep
|
|
# this small on purpose: a profile that picks an unknown quant is a bug, not
|
|
# a free-form string.
|
|
_ALLOWED_QUANT = frozenset(
|
|
{"none", "4bit", "8bit", "gptq", "awq", "fp8", "mxfp4", "hqq:4bit", "hqq:8bit"}
|
|
)
|
|
_ALLOWED_PEFT = frozenset({"lora", "dora", "qlora", "full"})
|
|
_ALLOWED_RUNTIME = frozenset(
|
|
{"transformers", "vllm", "sglang", "mlx", "ollama", "lm-studio", "executorch"}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeployProfile:
|
|
"""One canonical deploy-target combo entry."""
|
|
|
|
name: str
|
|
description: str
|
|
runtime: str
|
|
quant: str
|
|
peft: str
|
|
spec_decoding: bool
|
|
recommended_max_length: int
|
|
notes: str
|
|
|
|
|
|
def _make(
|
|
name: str,
|
|
description: str,
|
|
runtime: str,
|
|
quant: str,
|
|
peft: str,
|
|
spec_decoding: bool,
|
|
recommended_max_length: int,
|
|
notes: str = "",
|
|
) -> DeployProfile:
|
|
if not isinstance(name, str) or not _PROFILE_NAME_RE.match(name):
|
|
raise ValueError(
|
|
"profile name must be kebab-case ([a-z0-9][a-z0-9-]{0,31})"
|
|
)
|
|
if runtime not in _ALLOWED_RUNTIME:
|
|
raise ValueError(f"runtime {runtime!r} not in allowlist")
|
|
if quant not in _ALLOWED_QUANT:
|
|
raise ValueError(f"quant {quant!r} not in allowlist")
|
|
if peft not in _ALLOWED_PEFT:
|
|
raise ValueError(f"peft {peft!r} not in allowlist")
|
|
if not isinstance(spec_decoding, bool):
|
|
raise TypeError("spec_decoding must be bool")
|
|
if isinstance(recommended_max_length, bool) or not isinstance(
|
|
recommended_max_length, int
|
|
):
|
|
raise TypeError("recommended_max_length must be int (not bool)")
|
|
if not (64 <= recommended_max_length <= 1_048_576):
|
|
raise ValueError("recommended_max_length must be in [64, 1048576]")
|
|
for label, txt in (("description", description), ("notes", notes)):
|
|
if not isinstance(txt, str) or "\x00" in txt:
|
|
raise ValueError(f"{label} must be NUL-free string")
|
|
if len(txt) > 512:
|
|
raise ValueError(f"{label} exceeds 512 chars")
|
|
return DeployProfile(
|
|
name=name,
|
|
description=description,
|
|
runtime=runtime,
|
|
quant=quant,
|
|
peft=peft,
|
|
spec_decoding=spec_decoding,
|
|
recommended_max_length=recommended_max_length,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
_BUILTIN: Mapping[str, DeployProfile] = MappingProxyType(
|
|
{
|
|
"mac-m3": _make(
|
|
"mac-m3", "Apple Silicon M3 / M3 Pro / M3 Max — MLX inference",
|
|
"mlx", "4bit", "lora", False, 8192,
|
|
"MLX backend handles quantisation; LoRA stays as adapter",
|
|
),
|
|
"mac-m4-pro": _make(
|
|
"mac-m4-pro", "Apple Silicon M4 / M4 Pro — MLX inference",
|
|
"mlx", "4bit", "lora", False, 16384,
|
|
"Higher memory band; doubles default context window",
|
|
),
|
|
"rtx-3060-12gb": _make(
|
|
"rtx-3060-12gb", "Consumer NVIDIA 12GB — 4bit + speculative decoding",
|
|
"transformers", "4bit", "qlora", True, 4096,
|
|
),
|
|
"rtx-4090-24gb": _make(
|
|
"rtx-4090-24gb", "Consumer NVIDIA 24GB — AWQ + vLLM",
|
|
"vllm", "awq", "lora", True, 8192,
|
|
),
|
|
"iphone-16": _make(
|
|
"iphone-16", "iPhone 16 / 16 Pro — ExecuTorch on-device",
|
|
"executorch", "4bit", "qlora", False, 2048,
|
|
"Plan-only; ExecuTorch packaging lands in v0.54.0 Part D",
|
|
),
|
|
"pixel-9": _make(
|
|
"pixel-9", "Pixel 9 — ExecuTorch / AICore on-device",
|
|
"executorch", "4bit", "qlora", False, 2048,
|
|
"Plan-only; export pipeline lands in v0.54.0",
|
|
),
|
|
"ollama-local": _make(
|
|
"ollama-local", "Local Ollama / llama.cpp via GGUF",
|
|
"ollama", "4bit", "lora", False, 4096,
|
|
),
|
|
"lm-studio": _make(
|
|
"lm-studio", "LM Studio desktop app — GGUF model",
|
|
"lm-studio", "4bit", "lora", False, 4096,
|
|
),
|
|
"runpod-a100": _make(
|
|
"runpod-a100", "RunPod A100 40GB — bf16 vLLM with speculative",
|
|
"vllm", "none", "lora", True, 32768,
|
|
),
|
|
"hf-jobs-h100": _make(
|
|
"hf-jobs-h100", "HF Jobs H100 80GB — FP8 + vLLM prefix cache",
|
|
"vllm", "fp8", "lora", True, 65536,
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def list_profiles() -> Mapping[str, DeployProfile]:
|
|
"""Return an immutable view of the deploy profile registry."""
|
|
return _BUILTIN
|
|
|
|
|
|
def get_profile(name: str) -> DeployProfile:
|
|
"""Return the profile for ``name`` (case-insensitive)."""
|
|
if not isinstance(name, str):
|
|
raise TypeError("name must be a string")
|
|
canonical = name.strip().lower()
|
|
if not canonical or "\x00" in canonical:
|
|
raise ValueError("name must be a non-empty NUL-free string")
|
|
if canonical not in _BUILTIN:
|
|
raise KeyError(canonical)
|
|
return _BUILTIN[canonical]
|
|
|
|
|
|
def has_profile(name: str) -> bool:
|
|
"""True when ``name`` resolves to a known profile."""
|
|
if not isinstance(name, str):
|
|
return False
|
|
return name.strip().lower() in _BUILTIN
|
|
|
|
|
|
def _validate_base(base: str) -> str:
|
|
if not isinstance(base, str):
|
|
raise TypeError("base must be a string")
|
|
if not base or "\x00" in base or "\n" in base or "\r" in base:
|
|
raise ValueError("base must be a non-empty single-line NUL-free string")
|
|
if len(base) > _MAX_BASE_LEN:
|
|
raise ValueError(f"base exceeds {_MAX_BASE_LEN} chars")
|
|
return base
|
|
|
|
|
|
def render_recipe_yaml(profile: DeployProfile, base: str, output_dir: str) -> str:
|
|
"""Render a ready-to-train ``soup.yaml`` for ``profile`` + ``base``.
|
|
|
|
Returns the YAML *text*; callers handle write-out with their own
|
|
containment checks. The output_dir is validated as a non-empty
|
|
NUL/newline-free string but is NOT path-realpath'd here so the recipe
|
|
can be re-used from a different working dir later.
|
|
"""
|
|
if not isinstance(profile, DeployProfile):
|
|
raise TypeError("profile must be a DeployProfile")
|
|
base = _validate_base(base)
|
|
if not isinstance(output_dir, str):
|
|
raise TypeError("output_dir must be a string")
|
|
if (
|
|
not output_dir
|
|
or "\x00" in output_dir
|
|
or "\n" in output_dir
|
|
or "\r" in output_dir
|
|
):
|
|
raise ValueError("output_dir must be a non-empty single-line NUL-free string")
|
|
if len(output_dir) > _MAX_OUTPUT_PATH_LEN:
|
|
raise ValueError(f"output_dir exceeds {_MAX_OUTPUT_PATH_LEN} chars")
|
|
peft_section = ""
|
|
if profile.peft in ("lora", "qlora", "dora"):
|
|
peft_section = (
|
|
" lora:\n"
|
|
" r: 16\n"
|
|
" alpha: 32\n"
|
|
" dropout: 0.05\n"
|
|
)
|
|
if profile.peft == "dora":
|
|
peft_section += " use_dora: true\n"
|
|
lines = [
|
|
f"# Soup deploy autopilot — profile: {profile.name}",
|
|
f"# {profile.description}",
|
|
f"base: {base}",
|
|
"task: sft",
|
|
f"backend: {'mlx' if profile.runtime == 'mlx' else 'transformers'}",
|
|
"data:",
|
|
" train: ./data.jsonl",
|
|
" format: auto",
|
|
f" max_length: {profile.recommended_max_length}",
|
|
"training:",
|
|
" epochs: 3",
|
|
" lr: 2.0e-5",
|
|
" batch_size: auto",
|
|
f" quantization: {profile.quant}",
|
|
]
|
|
if peft_section:
|
|
lines.append(peft_section.rstrip("\n"))
|
|
lines.append(f"output: {output_dir}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_deploy_script(profile: DeployProfile, model_path: str) -> str:
|
|
"""Render the shell script that takes the trained model to the target.
|
|
|
|
The script is intentionally a stub: it prints the planned ``soup serve``
|
|
or ``soup deploy`` command rather than executing it, so the user keeps
|
|
the final approval gate. ``model_path`` is shell-escaped via
|
|
``shlex.quote`` so a crafted path cannot inject shell syntax.
|
|
"""
|
|
if not isinstance(profile, DeployProfile):
|
|
raise TypeError("profile must be a DeployProfile")
|
|
if not isinstance(model_path, str):
|
|
raise TypeError("model_path must be a string")
|
|
if (
|
|
not model_path
|
|
or "\x00" in model_path
|
|
or "\n" in model_path
|
|
or "\r" in model_path
|
|
):
|
|
raise ValueError("model_path must be non-empty single-line NUL-free string")
|
|
if len(model_path) > _MAX_OUTPUT_PATH_LEN:
|
|
raise ValueError(f"model_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
|
|
quoted = shlex.quote(model_path)
|
|
header = (
|
|
"#!/usr/bin/env bash\n"
|
|
"set -euo pipefail\n"
|
|
f"# Soup deploy autopilot — profile: {profile.name}\n"
|
|
f"# {profile.description}\n"
|
|
f"MODEL={quoted}\n"
|
|
)
|
|
if profile.runtime == "ollama":
|
|
body = (
|
|
'NAME="soup-${USER:-local}-$(basename \"$MODEL\")"\n'
|
|
'echo "Planned: soup deploy ollama --model $MODEL --name $NAME"\n'
|
|
)
|
|
elif profile.runtime == "lm-studio":
|
|
body = 'echo "Planned: copy GGUF to ~/.cache/lm-studio/models/ then load in UI"\n'
|
|
elif profile.runtime == "executorch":
|
|
body = (
|
|
'echo "Plan-only: ExecuTorch packaging not yet implemented (v0.54.0)"\n'
|
|
'echo "Target: $MODEL"\n'
|
|
)
|
|
elif profile.runtime == "mlx":
|
|
body = 'echo "Planned: soup serve --backend mlx --model $MODEL"\n'
|
|
else:
|
|
spec_flag = " --auto-spec" if profile.spec_decoding else ""
|
|
body = (
|
|
'echo "Planned: soup serve --backend '
|
|
f'{profile.runtime} --model $MODEL{spec_flag}"\n'
|
|
)
|
|
return header + body
|
|
|
|
|
|
def _reject_symlink_target(path: str, label: str) -> None:
|
|
"""Reject if ``path`` already exists and is a symlink (TOCTOU defence).
|
|
|
|
Matches v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B policy.
|
|
"""
|
|
import stat as _stat
|
|
|
|
try:
|
|
st = os.lstat(path)
|
|
except FileNotFoundError:
|
|
return
|
|
if _stat.S_ISLNK(st.st_mode):
|
|
raise ValueError(
|
|
f"{label} must not be a symlink: {os.path.basename(path)}"
|
|
)
|
|
|
|
|
|
def write_recipe(
|
|
profile: DeployProfile,
|
|
base: str,
|
|
output_dir: str,
|
|
recipe_path: str,
|
|
) -> str:
|
|
"""Write the recipe YAML under cwd; returns the realpath written."""
|
|
if not isinstance(recipe_path, str):
|
|
raise TypeError("recipe_path must be a string")
|
|
if not recipe_path or "\x00" in recipe_path:
|
|
raise ValueError("recipe_path must be non-empty NUL-free string")
|
|
if len(recipe_path) > _MAX_OUTPUT_PATH_LEN:
|
|
raise ValueError(f"recipe_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
|
|
if not is_under_cwd(recipe_path):
|
|
raise ValueError(
|
|
f"recipe_path must stay under cwd: {os.path.basename(recipe_path)}"
|
|
)
|
|
_reject_symlink_target(recipe_path, "recipe_path")
|
|
text = render_recipe_yaml(profile, base=base, output_dir=output_dir)
|
|
real = os.path.realpath(recipe_path)
|
|
os.makedirs(os.path.dirname(real) or ".", exist_ok=True)
|
|
with open(real, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
return real
|
|
|
|
|
|
def write_deploy_script(
|
|
profile: DeployProfile, model_path: str, script_path: str
|
|
) -> str:
|
|
"""Write the deploy bash script under cwd; returns the realpath written."""
|
|
if not isinstance(script_path, str):
|
|
raise TypeError("script_path must be a string")
|
|
if not script_path or "\x00" in script_path:
|
|
raise ValueError("script_path must be non-empty NUL-free string")
|
|
if len(script_path) > _MAX_OUTPUT_PATH_LEN:
|
|
raise ValueError(f"script_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
|
|
if not is_under_cwd(script_path):
|
|
raise ValueError(
|
|
f"script_path must stay under cwd: {os.path.basename(script_path)}"
|
|
)
|
|
_reject_symlink_target(script_path, "script_path")
|
|
text = render_deploy_script(profile, model_path=model_path)
|
|
real = os.path.realpath(script_path)
|
|
os.makedirs(os.path.dirname(real) or ".", exist_ok=True)
|
|
with open(real, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
if os.name != "nt":
|
|
try:
|
|
os.chmod(real, 0o755)
|
|
except OSError:
|
|
pass
|
|
return real
|
|
|
|
|
|
def autopilot_artifacts(
|
|
profile_name: str,
|
|
base: str,
|
|
output_dir: str,
|
|
model_path: Optional[str] = None,
|
|
) -> Tuple[str, str]:
|
|
"""Return (recipe_yaml_text, deploy_script_text) for a profile.
|
|
|
|
Helper for callers that want both artifacts in-memory without writing
|
|
them out. ``model_path`` defaults to ``output_dir`` when omitted.
|
|
"""
|
|
profile = get_profile(profile_name)
|
|
recipe = render_recipe_yaml(profile, base=base, output_dir=output_dir)
|
|
script = render_deploy_script(profile, model_path=model_path or output_dir)
|
|
return recipe, script
|
|
|
|
|
|
__all__ = [
|
|
"DeployProfile",
|
|
"list_profiles",
|
|
"get_profile",
|
|
"has_profile",
|
|
"render_recipe_yaml",
|
|
"render_deploy_script",
|
|
"write_recipe",
|
|
"write_deploy_script",
|
|
"autopilot_artifacts",
|
|
]
|