Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ lmlite-models --source-dir /path/to/models --output-dir /path/to/site/files/mode

`--model Xenova/multilingual-e5-small:q8 --model HuggingFaceTB/SmolLM2-360M-Instruct:q4` hosts only the listed models, each at its own dtype (a bare `--model ID` uses `--dtype`, or hosts every variant); `manifest.json` lists exactly the hosted models, and a model missing from the source fails.

A model with no ONNX weights fails, in both formats, instead of being listed and failing to load in the browser. Symlinks are copied as their target file when it lies inside the model directory; others (such as a Hugging Face cache `snapshots/` tree pointing into `blobs/`) are skipped and named. Dereference those first, for example with `cp -rL`.

The output must be outside the source directory. Publish it over HTTP and pass its URL as `hosted_models_url` when automatic discovery does not apply.

Use `--format zip` explicitly to prepare portable model archives instead; ZIP mode caches archives in the source directory and reuses them on subsequent runs.
Expand Down
24 changes: 17 additions & 7 deletions src/lmlite/model_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,29 @@ def model_files(source: Path) -> list[tuple[Path, Path]]:
if not source.is_dir():
raise NotADirectoryError(f"model directory not found: {source}")

files = []
files, skipped = [], []
for path in sorted(source.rglob("*")):
if path.is_symlink() or not path.is_file():
continue
relative_path = path.relative_to(source)
if any(part in _EXCLUDED_PARTS for part in relative_path.parts) or path.suffix == ".bak":
continue
files.append((path, relative_path))
if path.is_symlink():
target = path.resolve()
# Copy link targets only from inside the model directory, so a link cannot publish unrelated files.
if not (target.is_file() and target.is_relative_to(source.resolve())):
print(f"skipping symlink {relative_path} -> {target}: not a file inside {source}", file=sys.stderr)
skipped.append(relative_path.as_posix())
continue
if path.is_file():
files.append((path, relative_path))

if not any(relative_path.as_posix() == "config.json" for _, relative_path in files):
raise ValueError(f"model directory is missing config.json: {source}")
if not files:
raise ValueError(f"no packable model files under {source}")
# A model without weights cannot load in the browser, so fail the build instead of publishing it.
if not any(onnx_dtype(relative_path) for _, relative_path in files):
found = ", ".join(relative_path.as_posix() for _, relative_path in files)
raise ValueError(
f"no ONNX weights under {source}; found: {found}; skipped symlinks: {', '.join(skipped) or 'none'}"
)
return files


Expand Down Expand Up @@ -220,7 +230,7 @@ def host_models(
shutil.copy2(path, destination)
copied.append(destination)
hosted_dtypes = sorted({onnx_dtype(relative_path) for _, relative_path in files} - {None})
print(f"hosting {model_id} ({', '.join(hosted_dtypes) or 'no ONNX weights'})")
print(f"hosting {model_id} ({', '.join(hosted_dtypes)})")
(output_dir / "manifest.json").write_text(json.dumps({"models": [model_id for model_id, _ in models]}) + "\n")
return copied

Expand Down
50 changes: 50 additions & 0 deletions tests/test_pack_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,56 @@ def test_package_cli_rejects_model_without_requested_dtype(tmp_path, capsys):
assert "org/model: none of the dtypes q8 found; available: q4" in capsys.readouterr().err


def _weight_source(tmp_path, weight):
"""A model whose only weight, onnx/model_q4.onnx, is a real file, a symlink inside or outside it, or missing."""
source = tmp_path / "models"
model = _write_model(source, "org/model")
link = model / "onnx" / "model_q4.onnx"
if weight == "inside":
link.rename(model / "weights.bin")
link.symlink_to("../weights.bin")
elif weight == "outside":
link.rename(tmp_path / "weights.bin")
link.symlink_to(tmp_path / "weights.bin")
elif weight == "missing":
link.unlink()
return source


@pytest.mark.parametrize("args", [["--format", "files"], ["--format", "files", "--dtype", "q4"], ["--format", "zip"]])
@pytest.mark.parametrize("weight", ["real", "inside"])
def test_package_cli_hosts_real_and_inside_symlinked_weights(tmp_path, weight, args):
source = _weight_source(tmp_path, weight)
output = tmp_path / "out"

model_assets.main(["--source-dir", str(source), "--output-dir", str(output), *args])

if args[1] == "zip":
with zipfile.ZipFile(output / "model-org-model.zip") as archive:
assert archive.read("org/model/onnx/model_q4.onnx") == b"onnx"
else:
hosted = output / "org/model/onnx/model_q4.onnx"
assert not hosted.is_symlink() and hosted.read_bytes() == b"onnx"
assert json.loads((output / "manifest.json").read_text()) == {"models": ["org/model"]}


@pytest.mark.parametrize("args", [["--format", "files"], ["--format", "files", "--dtype", "q4"], ["--format", "zip"]])
@pytest.mark.parametrize("weight", ["outside", "missing"])
def test_package_cli_rejects_model_without_hostable_weights(tmp_path, capsys, weight, args):
source = _weight_source(tmp_path, weight)
output = tmp_path / "out"

with pytest.raises(SystemExit) as error:
model_assets.main(["--source-dir", str(source), "--output-dir", str(output), *args])

err = capsys.readouterr().err
assert error.value.code == 2
assert "no ONNX weights under" in err
skipped = "onnx/model_q4.onnx" if weight == "outside" else "none"
assert f"skipped symlinks: {skipped}" in err
assert not (output / "manifest.json").exists()


def test_package_cli_supports_explicit_zip(tmp_path):
source = tmp_path / "models"
_write_model(source, "org/model")
Expand Down
Loading