Unified collection import/export format; LMX collections distributed via Hugging Face#2099
Unified collection import/export format; LMX collections distributed via Hugging Face#2099jeremyfowers wants to merge 5 commits into
Conversation
Collections (recipe collection.omni) now import/export exactly like
regular models — one JSON format shared by the server, GUI, CLI, and
Hugging Face:
- /models/{id} (and the /models list) embed an ordered models[] array
for collections: each component's full model object, parallel to
components[].
- Export (CLI `lemonade export` and the GUI Export button) fetches the
live /models/{id} object and normalizes it into an import-ready /pull
body via the shared transform (id -> model_name + user. prefix,
kKnownKeys allow-list, checkpoint dedup, per-element normalization).
Exported files never carry suggested/created/downloaded; the server
regenerates them on import.
- /pull accepts the exported file verbatim: unknown components are
registered from the embedded models[] definitions (local-wins + drift
warning for known names), then the normal component fan-out runs.
- HF-hosted collections (the built-in LMX-Omni stubs) use the same file
as their manifest, named <CollectionName>.json and discovered by
content (recipe == collection.omni) instead of a hardcoded filename.
- GUI: collection import is now a single POST /pull; the legacy
{version, collections, models} export bundle is removed (hard
cutover); export filenames drop the user. prefix.
- Docs: format documented on /pull (lemonade.md) and the model object
(openai.md).
Verified: server_endpoints.py 50/50, server_cli2.py 58/58,
server_omni.py 7/7 (pulls LMX-Omni-5.5B-Lite through the HF manifest
path and drives orchestration), app regression suite 31/31, and
`lemonade export LMX-Omni-5.5B-Lite` reproduces the uploaded HF
manifest exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… guide
Review fixes for the collection import/export feature:
- Bound model_info_to_json's collection embedding at depth 3 so a cyclic
collection registration cannot crash GET /v1/models
- Match handle_pull's canonicalization-skip condition to download_model's
collection-file branch (models must be an array)
- Require a complete cached manifest (components AND models arrays) before
do_not_upgrade skips the refresh in fetch_collection_manifest
- Apply the reserved-name check (and an empty-name guard) when registering
components from remote manifest content
- Roll back a collection registered earlier in the same pull when its
component resolution fails (no zombie user_models.json entries)
- Rename populate_collection_components_from_cache to *_locked per the
existing convention and document the mutex precondition
- Surface model-export failures in the desktop app modal instead of only
console.error
Docs: move the collection-file format and export/import/Hugging Face
workflows from the /v1/pull API reference into the custom models guide
("Share a collection"); the API page keeps an endpoint-specific note that
exported files work verbatim as /v1/pull bodies. Link the LMX omni models
table to their Hugging Face repos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`lemonade pull <org>/<repo>` previously only recognized GGUF and ONNX RyzenAI repositories; a repo carrying an exported collection manifest failed with "No supported model files". Variant discovery now detects a third repo kind: when a repo has no GGUF files but contains <RepoName>.json, that file is fetched (size-capped) and validated as a collection manifest (recipe "collection.omni" with components/models arrays). The filename is the discovery key; a same-named file that is not a manifest is a clear error rather than a fall-through. The CLI then uses the manifest as the /pull body directly (it is import-ready by design), with checkpoints.main set to the repo id so the registered collection is HF-backed and a later pull refreshes its definition from the repo. The unsupported-repo error now lists all three supported repo types, including the exact manifest filename that was looked for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f929003f5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // the manifest to learn its components. On an explicit pull | ||
| // (do_not_upgrade=false) always refresh so newly added components are | ||
| // picked up. | ||
| components = resolve_collection_components_from_manifest(repo_id, do_not_upgrade); |
There was a problem hiding this comment.
Persist refreshed collection components
When an already-registered HF-backed user collection is pulled again after its manifest changes, this branch fetches and downloads the refreshed component list but never writes components back to user_models.json. The cache invalidation below then rebuilds user collections from the persisted stale components (only built-in empty-component collections call populate_collection_components_from_cache_locked), so /models, load/routing, and downloaded status continue to reflect the old collection even though the new manifest was fetched.
Useful? React with 👍 / 👎.
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, this is a good direction overall: sharing one import/export shape across the server, CLI, GUI, and HF manifests should simplify the collection story a lot.
Before merge let's fix two correctness issues:
-
I agree with the existing Codex inline comment on
src/cpp/server/model_manager.cpp:3139: when an already-registered HF-backed user collection is refreshed from a changed manifest, the refreshed component list is used for the current download but is not persisted back into user_models.json. After cache invalidation/rebuild, the registered collection can still expose/load the stale components. Please persist the canonical refreshedcomponentsforuser.*collections after manifest resolution, and add a regression test for manifest A/B -> A/B/C refresh. -
Inline collection imports should fail closed when components and models do not match. The format is documented as ordered/parallel; silently dropping missing or invalid component definitions can create a different collection than the exported file describes.
| register_user_model(canonical, def); | ||
| components.push_back(canonical); | ||
| } else { | ||
| LOG(WARNING, "ModelManager") << "Skipping unknown collection component with no " |
There was a problem hiding this comment.
This should probably be a hard import failure for inline collection files, not a warning/skip. validate_collection_request() only checks that models is an array; after that, any unknown component without a matching valid definition is skipped here. If at least one component remains, the collection can be persisted with a shortened component list, which is not the same collection the exported file described.
Can we require models to match components exactly for inline imports (same names, no missing definitions, no invalid entries), and abort/rollback instead of silently dropping components?
| // the custom models guide). The filename is the discovery key; the content | ||
| // is then validated — a same-named file that is not a manifest is an | ||
| // error, not a fall-through. | ||
| const std::string manifest_filename = suggested_name + ".json"; |
There was a problem hiding this comment.
The implementation here requires the manifest to be named exactly .json. That matches the new user docs, but the PR description says HF manifests are discovered "by content" as the repo's *.json with recipe: collection.omni.
What
Makes omni collections (
recipe: collection.omni) behave like virtual models: they import/export through the exact same pipeline as regular models, using one JSON format shared by the server, GUI, CLI, and Hugging Face.The format
A collection file is the regular-model export format plus
components(ordered names) andmodels(each component's full definition, normalized by the same per-model transform):{ "model_name": "user.LMX-Omni-5.5B-Lite", "recipe": "collection.omni", "checkpoints": { "main": "lemonade-sdk/LMX-Omni-5.5B-Lite" }, "components": ["Qwen3.5-4B-MTP-GGUF", "SD-Turbo", "Whisper-Tiny", "kokoro-v1"], "models": [ { "model_name": "Qwen3.5-4B-MTP-GGUF", "recipe": "llamacpp", "checkpoints": { "...": "..." }, "labels": ["..."], "size": 3.66 }, "..." ], "labels": [], "recipe_options": {}, "size": 9.3 }The same file works verbatim as: a
POST /v1/pullbody, alemonade importfile, and an HF-hosted manifest (<CollectionName>.json).Example Model Files
https://huggingface.co/lemonade-sdk/LMX-Omni-52B-Halo
https://huggingface.co/lemonade-sdk/LMX-Omni-5.5B-Lite
Changes
/v1/modelsand/v1/models/{id}embed an orderedmodels[]array for collections (each component's full model object, parallel tocomponents)./v1/pullaccepts the exported file. Unknown components are registered from the embeddedmodels[]definitions; known names keep the local definition (local-wins, drift logged as a warning). The existing component fan-out, cycle guard, and progress streaming are unchanged.LMX-Omni-52B-Halo/LMX-Omni-5.5B-Liteentries are slim stubs (checkpoint= HF repo); pulling fetches the manifest from the repo, discovered by content (the repo's*.jsonwithrecipe: collection.omni) rather than a hardcoded filename. Manifests cache in the HF cache like any model file, so collections survive restarts and work offline after first pull.lemonade export/importwork for collections via the sharedvalidate_and_transform_model_jsontransform (id→model_name,kKnownKeysallow-list, checkpoint dedup, per-element normalization). Regular-model export output is byte-identical to before./models/{id}and save the normalized file (shared helper mirroring the CLI transform); collection import is a singlePOST /pull; export filenames drop theuser.prefix; the legacy{version, collections, models}bundle format is removed.suggested,created,downloadednever appear in files (top level or insidemodels[]); the server regenerates them on import./v1/pull(lemonade.md, incl. a "Collection Files: Export, Import, and Hugging Face" section) and the model object (openai.md).Breaking changes
{version, exportedAt, collections, models}) no longer import (hard cutover; the GUI was the only producer/consumer).Verification
server_endpoints.py: 50/50 OK ·server_cli2.py: 58/58 OK (1 skipped) ·server_omni.py --wrapped-server llamacpp --backend vulkan: 7/7 OK (pre-pullsLMX-Omni-5.5B-Litethrough the HF manifest path, then drives the server-side orchestration loop)user.*from the embedded def, downloaded, survives restart); verbatimPOST /pullof an exported file🤖 Generated with Claude Code