Repository navigation
feat(docker): bake checkpoints from ModelScope at build time - #720
Conversation
A host that cannot reach huggingface.co cannot build the quickstart image either. docker/prefetch_modelscope.py lists a ModelScope repository, streams the checkpoint's own files with resume, verifies each one's size and SHA-256 against what the repository reports, and writes them into the image's hub cache laid out the way snapshot_download reads it offline: refs/<revision> naming an existing snapshots/<sha>. Nothing in the runtime changes, so Agent, the Router laya-serve builds, laya.cli and the integrations keep their repo ids and resolve them to the baked snapshot. One build argument selects the checkpoint -- multilingual by default, or english, typed-decisions, all -- and a type expands to that checkpoint's path inside the bundled repository, which is what the Router and the quickstart load by default. A repo[:subfolder] spec is accepted too, for the mirror's standalone repositories or a fine-tuned checkpoint. MODELSCOPE_MODEL is empty by default, so a plain build is unchanged; the Compose override sets the multilingual type and keeps every service on the baked cache with HF_HUB_OFFLINE=1, repeating the arguments for laya-serve because overrides for laya never reach it. A partial download is never visible to a loader: the snapshot is renamed into place only after every file is verified, and the ref is written after the rename.
…olve A cached revision resolves to a single directory, so baking the English checkpoint (at the bundle repository's root) and the multilingual one (under its subfolder) as two snapshots left only the last written refs/main resolvable: the English request that auto-routing sends there answered 500 inference failed with "does not contain rl_agent_config.json". One repository now bakes one snapshot, keyed by the tip of the requested revision, and every checkpoint of that repository lands in it. Found by building the image and running an English request through it; the merged layout is what the real mirror produces too (its files sit across several uploads, so a checkpoint's own commit is not a repository-wide key). main() also passed a spec string where prefetch() wanted a (repo, subfolder) pair -- a CLI-level test now drives the whole argument, from --model to the snapshot on disk.
f838d78 to
f69bf11
Compare
Bruce-Yii
left a comment
There was a problem hiding this comment.
The previous hold on this was wrong: this PR is not unverifiable. About 80% of it verifies without a build, and I have that 80% green. What is actually open is a one-paragraph documentation bug — plus a CI approval only you can do.
Verified without a build
tests/test_modelscope_prefetch.py → 20 passed. tests/test_packaging.py → 121 passed. No new third-party dependency exists to declare: docker/prefetch_modelscope.py is pure stdlib, modelscope is never imported anywhere in the repo, pyproject.toml is untouched, and the script is standalone behind if __name__ == "__main__". Zero files under laya/ change, so the default Hugging Face path is untouched and plain docker build . is a no-op (Dockerfile:36 defaults the arg empty, :74 short-circuits).
The tests are not vacuous, which matters given the claim. The bake tests drive a stubbed ModelScope API and then assert against the real snapshot_download(..., local_files_only=True) plus real file bytes, so a regressed cache layout cannot pass; the three negative tests assert a failed download leaves nothing behind. Your checkpoint-resolution logic, the digest/size checks and the fail-closed direction are all genuinely covered.
The only part that still needs a build is the Docker/Compose layer — 196 of 962 lines. So this splits cleanly by file: prefetch_modelscope.py + its test + the one ci.yml line (766 lines, green today) can land on its own merits, leaving the image wiring for a build-capable review.
The actual bug: the baked weights are shadowed by the cache volume
This is why nobody can reproduce your end-to-end claim on an existing host, and it is a recipe problem, not a code problem.
Dockerfile:56setsHF_HOME=/home/laya/.cache/huggingface, and:78bakes into--cache-dir "$HF_HOME/hub".compose.yaml:29mountsmodel-cache:/home/laya/.cache/huggingface— the exact parent directory of what you bake into.compose.yaml:34gives that volume a stable, persistent name:${LAYA_CACHE_VOLUME:-${COMPOSE_PROJECT_NAME}_model-cache}.
Docker seeds a named volume from the image only when the volume is empty. On any host that has run the documented quickstart before — which is the default path — that volume already holds a Hub-downloaded snapshot whose refs/main points at a Hub commit. Your build bakes the ModelScope tip into the image; the volume is never re-seeded, so the baked tip is invisible at runtime. The loader resolves refs/main to the old Hub commit and silently serves the old weights, while the operator reasonably believes MODELSCOPE_MODEL=english took effect. And with HF_HUB_OFFLINE defaulting to 1 (compose.modelscope.yaml:32,39), a missing or divergent ref becomes a hard load failure with no network to fall back to.
compose.modelscope.yaml:9 asserts the opposite of what happens on a used host — "the weights land in the hub cache the image already mounts as the model-cache volume" — which is only true for a fresh volume. And the prerequisite is documented nowhere: docs/docker.md mentions LAYA_CACHE_VOLUME only in the environment-variable table, and docker compose down --volumes only under deleting downloaded weights.
Fix: state the prerequisite in compose.modelscope.yaml and in the ModelScope section of docs/docker.md (use a fresh LAYA_CACHE_VOLUME, or down --volumes first), or bake to a path outside the mounted volume. The first is a paragraph.
The thing only you can do: this PR has never run CI
All four check suites on f69bf111 are conclusion=action_required with latest_check_runs_count=0. The cause is that the head repo is a fork (cgq0816/laya), so GitHub is holding the pull_request workflows for approval. So #720 is not red — it is unrun, and there is no signal from it in either direction. Approving that run is a one-click unblock, and the py3.10–py3.13 lane would exercise the new suite, which I have already reproduced green locally.
For the record, neither of the two repo-level causes people tend to blame is implicated: docs (strict build) and dependency CVEs did not run here at all.
Minor
- The body says "19 new offline tests"; there are 20 (your second commit added
test_main_drives_the_whole_argument). - The suite is wired into
ci.ymlbut notrelease.yml, which is a pre-existing gap —release.ymlcurrently omits 37 of 79 test files — so not something this PR introduced. - The "verifies each file's SHA-256" claim is conditional: the check only fires when the mirror publishes a digest, and an absent digest degrades to size-only. Worth a line in the docs' limits section, along with the absence of a digest pin, since the mirror account is the operator's supply-chain decision.
compose.modelscope.yamlis not intests/test_compose_env.py'sCOMPOSE_FILESlist, so it gets no env-passthrough or device coverage. Parse coverage does exist via the four stackeddocker compose config --quietcombinations indocker.yml.
Recommendation: mergeable on the code, not on the recipe. Land Subset A now if you want it reviewed without a build; the volume-shadowing paragraph is the whole of what stands between this and a clean approval.
Docker seeds a named volume from the image only while it is empty, so on a host that has already run the quickstart the model-cache volume still holds the older Hub snapshot and the baked weights stay invisible behind it: refs/main resolves to the old Hub commit and the old weights are served. compose.modelscope.yaml and the ModelScope section of docs/docker.md claimed the opposite for such a host -- state the prerequisite in both (down --volumes with the same Compose files, or a fresh LAYA_CACHE_VOLUME). While there, make the digest claim conditional the way prefetch_modelscope.py implements it: the SHA-256 check fires only when the mirror repository publishes one, and a repository that publishes none degrades the check to size alone; no digest pin exists for a mirror snapshot.
…uite The file the ModelScope PR ships was missing from COMPOSE_FILES, so the suite that derives every runtime LAYA_* name from laya/agent.py and checks it in both directions never parsed it. It forwards LAYA_MODELS as a host passthrough, which is what this suite asserts elsewhere; adding it keeps the check that every forwarded name reaches the docs and vice versa true for the new file too.
|
@Bruce-Yii, the review items are addressed on the current head,
Verification: 20 prefetch tests, 121 packaging checks, and 87 Compose environment checks pass. All four ModelScope Compose combinations parse successfully; Ruff and compilation checks pass. Using the existing baked image, I also verified offline English/German inference with a fresh volume and confirmed that reusing a populated volume preserves its old ref and shadows the image's baked ref. This was runtime verification of the existing image, not a new image build. CI remains awaiting maintainer approval. All four check suites on |
|
Merged for 0.3.23. Both review items are addressed on the head I took: the cache-volume prerequisite is documented in To be explicit about scope, since this adds a second place weights can come from: nothing under
Thank you. |
Why
A host that cannot reach huggingface.co cannot build the quickstart image either. The checkpoints are also published on the ModelScope mirror, so a build can bake them in and the image then runs with no Hub access at all.
What
docker/prefetch_modelscope.pylists the mirror repository, streams the checkpoint's own files with resume, verifies each file's size and, when the mirror publishes a digest, its SHA-256 against the repository metadata, and writes them into the image's hub cache laid out the waysnapshot_downloadreads offline. Nothing underlaya/changes:Agent, theRouterthat laya-serve builds,laya.cliand the integrations keep their repo ids and resolve to the baked snapshot.One build argument selects the checkpoint:
MODELSCOPE_MODEL=multilingualby default, orenglish,typed-decisions,all, or explicitrepo[:subfolder]specs. Empty by default, so a plaindocker build .behaves exactly as before.compose.modelscope.yamlsets the multilingual type for both services and keeps them on the baked cache withHF_HUB_OFFLINE=1; it repeats the arguments forlaya-serve, because an override forlayanever reaches it.Checkpoints of one repository bake into a single snapshot: a cached revision resolves to one directory, so baking the root checkpoint and a subfolder separately would leave only the last
refs/mainresolvable. A download that fails its size or digest check publishes nothing, and the snapshot is renamed into place only after every file is verified.The SDK guide link now points to the repository README, fixing a pre-existing broken relative link that blocked strict documentation builds.
Verification
Built the image and ran it with no network: the one-shot quickstart routed English input to
englishand German input tomultilingual, both returning real predictions, andlaya-serveansweredGET /healthandPOST /v1/systemonethe same way. Also checked the documented Compose combination behind a published port. 20 offline tests intests/test_modelscope_prefetch.py, 121 packaging checks, and 87 Compose environment checks pass. All four ModelScope Compose combinations parse successfully; strict documentation build, Ruff, and compile checks pass. Rechecked offline English/German inference using the existing baked image with a fresh volume, and confirmed that a populated volume preserves its old ref.Limits
Only what was baked loads offline. An image that baked only
multilingualanswers500 inference failedon English traffic unless the client pinsmodelor the image also bakesenglish; this is documented indocs/docker.md, together with the recovery knobs.A Compose deployment must use an empty model-cache volume: select a fresh LAYA_CACHE_VOLUME, or remove the old cache with docker compose down --volumes using the same Compose files and volume setting. Docker does not re-seed a populated volume, so it otherwise hides the baked snapshot. This prerequisite is documented in both compose.modelscope.yaml and docs/docker.md.
When the mirror supplies no digest, verification is size-only and cannot detect a same-size substitution. Mirror metadata is not an independently pinned digest; the selected mirror account is the operator's supply-chain decision.