Skip to content
Draft
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ Modly supports external model and process extensions. Each extension is a GitHub

![Install models](docs/install-models.png)

### Multiple Hugging Face repositories per model node

A model node whose weights are split across repositories can declare
`model_sources`. Modly validates every source, downloads them sequentially in
one Models-page action, and considers the node installed only when every
declared check exists.

```json
{
"id": "generate",
"model_sources": [
{
"id": "primary",
"provider": "huggingface",
"repo_id": "org/main-model",
"destination": ".",
"checks": ["model.safetensors"]
},
{
"id": "encoder",
"provider": "huggingface",
"repo_id": "org/encoder",
"revision": "v1.0",
"destination": "auxiliary/encoder",
"include_prefixes": ["config.json", "model.safetensors"],
"checks": ["config.json", "model.safetensors"]
}
]
}
```

`destination`, filters, and checks use safe POSIX paths relative to the node's
model directory. The only supported provider is `huggingface`. Existing nodes
that use `hf_repo`, `download_check`, `hf_include_prefixes`, and
`hf_skip_prefixes` keep their original behavior.

---

## Workflows
Expand Down
164 changes: 162 additions & 2 deletions api/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from fastapi.responses import StreamingResponse
from services.generator_registry import generator_registry, MODELS_DIR
from services.model_sources import (
normalize_model_sources,
resolve_download_path,
resolve_model_root,
resolve_source_destination,
validate_source_file_plan,
)

router = APIRouter(tags=["model"])

Expand Down Expand Up @@ -97,7 +104,7 @@ async def unload_all_models():
return {"unloaded": True}


@router.post("/unload/{model_id}")
@router.post("/unload/{model_id:path}")
async def unload_model(model_id: str):
"""Unloads a model from memory so its files can be safely deleted."""
try:
Expand All @@ -122,6 +129,159 @@ async def cancel_hf_download(model_id: str):
return {"cancelled": True}


@router.post("/hf-download-sources")
async def hf_download_sources(request: FastAPIRequest, model_id: str):
"""Download all Hugging Face sources declared for one model node."""
try:
body = await request.json()
if not isinstance(body, dict):
raise ValueError("Request body must be an object")
sources = normalize_model_sources({"model_sources": body.get("sources")})
if sources is None:
raise ValueError("sources are required")
model_root = resolve_model_root(MODELS_DIR, model_id)
destinations = {
source["id"]: resolve_source_destination(
MODELS_DIR, model_id, source["destination"]
)
for source in sources
}
except (TypeError, ValueError) as exc:
raise HTTPException(400, str(exc)) from exc

authorization = request.headers.get("authorization", "")
hf_token = (
authorization[7:].strip()
if authorization.lower().startswith("bearer ")
else os.environ.get("HUGGING_FACE_HUB_TOKEN")
or os.environ.get("HF_TOKEN")
or None
)
control = _new_download_control(model_id)

async def stream():
loop = asyncio.get_running_loop()

def _fmt(data: dict) -> str:
return f"data: {json.dumps(data)}\n\n"

try:
yield _fmt({"percent": 0, "status": "Listing repository files..."})
files_by_source: dict[str, list[str]] = {}

for source in sources:
_check_download_control(control)

def _list_files(current=source):
from huggingface_hub import list_repo_files
listed = list_repo_files(
current["repo_id"],
revision=current.get("revision"),
token=hf_token,
)
include = current.get("include_prefixes", [])
skip = current.get("skip_prefixes", [])
return [
filename for filename in listed
if (not include or any(filename.startswith(prefix) for prefix in include))
if not any(filename.startswith(prefix) for prefix in skip)
]

files = await loop.run_in_executor(None, _list_files)
if not files:
raise RuntimeError(
f'No files found in Hugging Face repo: {source["repo_id"]}'
)
destination = destinations[source["id"]]
for filename in files:
resolve_download_path(destination, filename)
files_by_source[source["id"]] = files

validate_source_file_plan(sources, files_by_source)
planned_files = [
(source, filename)
for source in sources
for filename in files_by_source[source["id"]]
]
total = len(planned_files)
yield _fmt({"percent": 1, "status": f"Downloading {total} files..."})

from huggingface_hub import hf_hub_url

for index, (source, filename) in enumerate(planned_files):
_check_download_control(control)
display_file = f'{source["id"]}/{filename}'
base_percent = 1 + round(index / total * 94)
yield _fmt({
"percent": base_percent,
"file": display_file,
"fileIndex": index + 1,
"totalFiles": total,
"status": f"Starting {display_file}",
"bytesDownloaded": 0,
"stalledSeconds": 0,
})

queue: asyncio.Queue[dict] = asyncio.Queue()

def _progress(message: dict) -> None:
message["file"] = display_file
loop.call_soon_threadsafe(queue.put_nowait, message)

url = hf_hub_url(
repo_id=source["repo_id"],
filename=filename,
revision=source.get("revision"),
)
future = loop.run_in_executor(
None,
lambda: _download_file_streamed(
url=url,
filename=filename,
dest_dir=str(destinations[source["id"]]),
file_index=index + 1,
total_files=total,
base_percent=base_percent,
progress_cb=_progress,
control=control,
token=hf_token,
),
)
while not future.done():
try:
message = await asyncio.wait_for(queue.get(), timeout=2.0)
except asyncio.TimeoutError:
continue
yield _fmt(message)

final_size = await future
_check_download_control(control)
yield _fmt({
"percent": 1 + round((index + 1) / total * 94),
"file": display_file,
"fileIndex": index + 1,
"totalFiles": total,
"status": "Downloaded",
"bytesDownloaded": final_size,
"stalledSeconds": 0,
})

yield _fmt({"percent": 100, "status": "done"})
except DownloadPaused:
yield _fmt({"paused": True, "status": "paused"})
except DownloadCancelled:
for part in model_root.rglob("*.part"):
part.unlink(missing_ok=True)
yield _fmt({"cancelled": True, "status": "cancelled"})
except Exception as exc:
yield _fmt({"error": str(exc)})
finally:
if _download_controls.get(model_id) is control:
_download_controls.pop(model_id, None)

return StreamingResponse(stream(), media_type="text/event-stream")


@router.get("/hf-download")
async def hf_download(
repo_id: str,
Expand Down
27 changes: 24 additions & 3 deletions api/services/generator_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from services.generators.base import BaseGenerator
from services.extension_process import ExtensionProcess, _venv_python
from services.model_sources import model_sources_are_downloaded, normalize_model_sources

# ------------------------------------------------------------------ #
# Global paths
Expand Down Expand Up @@ -428,6 +429,9 @@ def _discover_extensions(
ext_id = manifest["id"]
class_name = manifest["generator_class"]

if "model_sources" in manifest:
raise ValueError("model_sources must be declared on a model node")

if ext_id != ext_dir.name:
message = (
f"Extension folder '{ext_dir.name}' declares mismatched "
Expand Down Expand Up @@ -523,6 +527,7 @@ def _discover_extensions(

if nodes:
for node in nodes:
model_sources = normalize_model_sources(node)
node_manifest = {
**manifest,
"id": f"{ext_id}/{node['id']}",
Expand All @@ -537,6 +542,8 @@ def _discover_extensions(
"input": node.get("input", "image"),
"output": node.get("output", "mesh"),
}
if model_sources is not None:
node_manifest["model_sources"] = model_sources
full_id = f"{ext_id}/{node['id']}"
result[full_id] = (cls_or_None, node_manifest, ext_dir, legacy_context)
if subprocess_mode:
Expand Down Expand Up @@ -699,8 +706,14 @@ def get_active(self) -> BaseGenerator:
"""Returns the active generator. Downloads and loads if necessary."""
self._assert_not_quarantined(self._active_id)
gen = self._generators[self._active_id]
downloaded = self._is_downloaded(self._active_id, gen)
if "model_sources" in self._manifests[self._active_id] and not downloaded:
raise RuntimeError(
"Model sources are incomplete. Download this node's weights "
"from the Modly Models page before generation."
)
if not gen.is_loaded():
if not gen.is_downloaded():
if not downloaded:
if isinstance(gen, ExtensionProcess):
# Let the subprocess handle its own download logic during
# load() — some extensions (e.g. mv-adapter) need custom
Expand Down Expand Up @@ -743,13 +756,21 @@ def switch_model(self, model_id: str) -> None:
# Status
# ------------------------------------------------------------------ #

def _is_downloaded(self, model_id: str, gen: BaseGenerator) -> bool:
manifest = self._manifests[model_id]
if "model_sources" in manifest:
return model_sources_are_downloaded(
MODELS_DIR, model_id, manifest["model_sources"]
)
return gen.is_downloaded()

def active_status(self) -> dict:
gen = self._generators[self._active_id]
manifest = self._manifests[self._active_id]
return {
"id": self._active_id,
"name": manifest.get("name", gen.DISPLAY_NAME),
"downloaded": gen.is_downloaded(),
"downloaded": self._is_downloaded(self._active_id, gen),
"loaded": gen.is_loaded(),
}

Expand All @@ -765,7 +786,7 @@ def all_status(self) -> list:
"vram_gb": manifest.get("vram_gb", gen.VRAM_GB),
"hf_repo": manifest.get("hf_repo", ""),
"tags": manifest.get("tags", []),
"downloaded": gen.is_downloaded(),
"downloaded": self._is_downloaded(model_id, gen),
"loaded": gen.is_loaded(),
"active": model_id == self._active_id,
})
Expand Down
Loading