Checkpoint integrity
Checkpoint integrity
Laya downloads model weights from the Hugging Face Hub at load time. By default it takes whatever the repository’s default revision points at, which is convenient and is what an offline cache already holds. If you would rather pin a reviewed commit, or refuse to load a checkpoint whose bytes have changed, both are available and both are opt-in.
Nothing here changes what Laya loads until you ask for it, so adding these options to an existing
deployment is safe. Both are library-level: digests reach the HTTP server through an environment
variable, and revision pinning through LAYA_REVISION — see Pinning a revision in the server.
Related: Docker for deployment variables, laya.load and Agent,
and Router.
Pin a revision
Pass revision to any loader. It accepts a commit SHA, a branch, or a tag, and is forwarded to the
Hub.
import laya
agent = laya.load("convaiinnovations/laya", revision="<commit sha, branch, or tag>")
print(agent.revision) # what the download resolved to
Prefer the reviewed SHAs that ship with Laya over a literal of your own: they are updated with the checkpoints, so this form cannot go stale.
from laya import PINNED_REVISIONS
agent = laya.load(
"convaiinnovations/laya",
revision=PINNED_REVISIONS["convaiinnovations/laya"],
)
Router takes the same revision, and revisions to pin each checkpoint separately. The keys of
PINNED_REVISIONS are the three standalone repositories, so pin a Router with
standalone_repos=True:
router = laya.Router(standalone_repos=True, revisions={
"english": PINNED_REVISIONS["convaiinnovations/laya"],
"multilingual": PINNED_REVISIONS["convaiinnovations/laya-multilingual"],
"typed-decisions": PINNED_REVISIONS["convaiinnovations/laya-typed-decisions"],
})
This matters because a default Router loads all three checkpoints from the one bundle repository
(convaiinnovations/laya, with multilingual/ and typed-decisions/ as subfolders), and a commit
SHA from laya-multilingual does not exist in the bundle repository. Without standalone_repos
the pin is not merely ignored — the load fails. If you would rather stay on the bundle repository,
pin it with one revision= for all three instead of per-model revisions=.
Pin all three even if you only serve two. A Router offers every checkpoint it knows about
regardless of what you preload, so an unpinned entry is one routing decision away from loading
unpinned.
Why pinning is not the default
Pinning by default would break loading from an older cached snapshot, which matters for on-device
and air-gapped deployments: HF_HUB_OFFLINE=1 with a cache that predates the pin would stop
working. Laya therefore keeps the Hub default unless you pass a revision, and makes the reviewed
SHAs available for when you want them.
Verify artifact digests
A pinned revision says which commit to fetch. A digest says which bytes you expect. Every file
you list is hashed before any of them is parsed and before weights reach the runtime. The map is
{path relative to the checkpoint: sha256 hex} — generate it first, then pass it.
Do you need both?
A pinned revision already fixes the content: the Hub is git, so the commit determines the tree, and large files are addressed by their own SHA-256. If you pin and the download succeeds, you have the bytes that commit names. So the digest is not there to repeat that check — it differs in what it trusts.
A revision asks the Hub for a commit and believes the answer. A digest is a record you made and you keep, compared on every load. That buys three things pinning does not:
- Cover for the common case, which is unpinned. Pinning is opt-in and off by default, so most deployments follow a moving branch. A digest is then the only thing that notices a change.
- A check on your own disk. After download, the checkpoint is ordinary files in a cache that anything on the box can edit. Nothing re-verifies them at load time — except a digest.
- Independence from the source. If a mirror, proxy or the Hub itself served different bytes, the digest is the only control not asking the thing under test to vouch for itself.
That independence is also why generating the map is a manual step: a fingerprint stops being an independent record the moment the thing it checks produces it for you.
Generate the map
Generate it from a checkpoint you have reviewed rather than copying digests from anywhere,
including this page. There is deliberately no command that produces this for you: a map computed
from the copy Laya just downloaded would hash those bytes and then verify them against themselves.
The check is worth something only because a person decided the bytes were the ones they wanted, so
generating the map is the step where that decision gets recorded. A map belongs to exactly one checkpoint: the bundle repository holds a
different rl_agent_config.json in its root (the english checkpoint) than in multilingual/, so a
map generated from one will fail against the other.
import hashlib, json, os
CHECKPOINT = "/path/to/checkpoint" # the directory a load actually reads
FILES = [
"rl_agent_config.json",
"tokenizer/tokenizer.json",
"encoder/config.json",
"model.safetensors",
]
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
digests = {rel: sha256(os.path.join(CHECKPOINT, rel)) for rel in FILES}
with open("digests.json", "w") as f:
json.dump(digests, f, indent=2)
A torch Agent load parses five files, and those are four of them. (ONNXAgent reads a different
set and additionally accepts the keys onnx and onnx_path to digest the graph itself.) The fifth,
tokenizer/tokenizer_config.json, is deliberately left out: Laya may normalise it after
verification and write it back, in which case pinning it makes the next load fail. That rewrite
is conditional — it fires only when the file declares no tokenizer_class, or declares
TokenizersBackend, or carries extra_special_tokens as a list — so on some checkpoints it never
happens and pinning the file would appear to work. Leaving it out is the portable choice, and it
means one parsed file is unverified. See
What this does and does not protect.
Use the map
import json
import laya
with open("digests.json") as f:
agent = laya.load("convaiinnovations/laya", expected_sha256=json.load(f))
Keys are paths relative to the checkpoint directory. A mismatch raises ValueError, and a listed
file that is absent raises FileNotFoundError. Files you do not list are not checked at all, so
the map is also the definition of what you are protecting. This works on a local directory as well
as a Hub download.
Without touching the code
LAYA_SHA256_DIGESTS holds the same map as JSON and applies whenever a loader is called without an
explicit expected_sha256:
export LAYA_SHA256_DIGESTS="$(cat digests.json)"
laya-serve
Nothing generates this for you: the value is your own map, from a checkpoint you reviewed. Under
Docker it has to be in the environment before compose starts, either exported as above or in the
.env file compose reads — the service passes ${LAYA_SHA256_DIGESTS:-} through, so an unset
variable silently means no verification:
echo "LAYA_SHA256_DIGESTS=$(cat digests.json)" >> .env
docker compose -f compose.yaml -f compose.http.yaml up laya-serve
There is no _FILE variant of this variable: that indirection exists for secrets, and a digest map
is not one.
An unset or empty variable means no verification, so this is safe to leave out of environments that do not need it. Malformed JSON raises rather than silently skipping the check.
Name each checkpoint when a process loads more than one. The variable takes two shapes, and the value types say which:
# one set of files, checked on every checkpoint the process loads
LAYA_SHA256_DIGESTS='{"rl_agent_config.json": "<sha256>"}'
# a map per checkpoint, which is what a router serving several needs
LAYA_SHA256_DIGESTS='{"english": {"rl_agent_config.json": "<sha256>"},
"multilingual": {"rl_agent_config.json": "<sha256>"}}'
The flat form is verify_digests’s own reading and applies the same paths to everything, so on a
router it can only ever match one checkpoint and refuses the rest. The bundled repository ships a
separate model.safetensors and rl_agent_config.json per checkpoint, so name them:
flat map generated from the english checkpoint
load english ok
load multilingual ValueError: laya: SHA-256 mismatch for rl_agent_config.json
A checkpoint the nested map does not name is deliberately unpinned rather than an error, and a model
name the router does not know raises instead of leaving that checkpoint unverified. en resolves to
english, the same normalisation Router(sha256_digests=...) applies.
The two routes differ on that last point, which is easy to trip over if you use both. A checkpoint
the environment’s nested map omits is pinned to an empty map, so a flat map cannot leak into it. A
checkpoint omitted from Router(sha256_digests=...) in code has no entry at all, so it still falls
back to whatever the environment says. Name every checkpoint you mean to pin in whichever one you
use.
An unset or empty variable means no verification, so this is safe to leave out of environments that do not need it. Malformed JSON raises rather than silently skipping the check, and mixing the two shapes in one object is refused by name.
What a mismatch looks like in the server
How it surfaces depends on preloading. Bare laya-serve preloads by default (LAYA_PRELOAD=1), so a
mismatch fails at startup — loud and deterministic. The containers in this repository set
LAYA_PRELOAD=0 (compose.http.yaml, and Docker documents the override), so there the
first load happens on a request and nothing is verified until then. A mismatch is then a 422 on
whichever ticket routes to that checkpoint: laya/serve.py maps the ValueError to
HTTPException(422) and returns the digest text to the caller. A listed but absent file raises
FileNotFoundError instead, which falls through to a generic 500 “inference failed” with the
reason only in the container log.
Plan for the 422. It sorts as a client error in logs, dashboards and alert rules, so the default place an operator looks for a broken deployment is the one place this will not appear.
Pinning a revision in the server
LAYA_REVISION holds a commit, branch or tag applied to every checkpoint download, or the word
reviewed, which looks each repository up in PINNED_REVISIONS and uses its own SHA:
LAYA_REVISION=reviewed laya-serve
reviewed for a repository the table has no entry for raises rather than loading it unpinned —
a pin that quietly resolves to nothing is the failure this control exists to prevent. An explicit
revision= argument still wins over the variable, and unset or blank means “not asked for”, so an
HF_HUB_OFFLINE=1 cache keeps loading exactly as before.
In code, Router takes both per model:
router = laya.Router(
revisions={"english": PINNED_REVISIONS["convaiinnovations/laya"]},
sha256_digests={"english": {"rl_agent_config.json": "<sha256>"}},
)
Digests are always per model — there is no router-wide equivalent of revision, because a commit SHA
can be shared across checkpoints and a digest cannot. See Docker for the deployment
variables and Router for the full constructor.
When a checkpoint is updated
The two controls behave differently, and only one of them needs anything from you.
A pinned revision keeps you where you are. A new checkpoint does not reach a pinned
deployment until you change the pin, which is the point of pinning. PINNED_REVISIONS moves with
the library, so taking a newer reviewed commit means upgrading Laya, not editing a SHA.
Digests stop the load, on purpose. Your map was generated from bytes you reviewed. Different
bytes raise ValueError before anything is parsed:
ValueError: laya: SHA-256 mismatch for rl_agent_config.json: expected ae287b56…, got 25061739…
That is the feature working, not a bug to route around. The order matters:
- Find out why the bytes changed — an intended release, or something you did not expect.
- Review the new checkpoint.
- Regenerate the map from the reviewed copy.
- Deploy the new map.
Do not skip to step 3. Re-running the generator against whatever just arrived makes the check pass and verifies nothing — it records the new bytes as trusted because they are present, which is precisely the state the digest existed to detect.
Two details. A new map delivered through LAYA_SHA256_DIGESTS needs the process restarted, because
a running server keeps the environment it started with. And this sequence only applies to a
deployment that is not revision-pinned: with both controls on, the new bytes never arrive until
you move the pin.
Confirm what actually loaded
Every agent records the commit it came from, None for a local directory:
agent.revision # Agent and ONNXAgent
router.loaded_revisions # {"english": "55cf4c4e…", …} for each resident agent
agent.revision reports the snapshot the download resolved to, falling back to whatever you passed,
so pinning by branch or tag echoes that name rather than a SHA — pin by SHA if you want this field
to be one. Loading from a local directory reports None, and revision is ignored there, because
there is no Hub snapshot to resolve.
The server reports the same thing, which is the quickest way to confirm a deployment is running the checkpoint you think it is:
curl -s localhost:8000/health
# {"status":"ok","loaded":["english"],"revisions":{"english":"55cf4c4e…"},"device":"auto"}
laya-ts
The TypeScript package mirrors the pinning and digest parts — revision, expectedSha256, and
reading the revision back. It has no LAYA_SHA256_DIGESTS equivalent and no server, so the two
sections above do not apply to it:
import { loadNodeBundle, PINNED_REVISIONS } from "laya-ts";
const bundle = await loadNodeBundle("convaiinnovations/laya", {
revision: PINNED_REVISIONS["convaiinnovations/laya"],
expectedSha256: { "rl_agent_config.json": "<sha256 of that file>" },
});
An explicit revision joins the on-disk cache path under ~/.cache/laya-ts/, so differently-pinned
artifacts never collide. In the browser the revision travels in the request URL instead, which
keys CacheStorage the same way. createNodeProvider accepts expectedSha256 for the ONNX graphs
it loads.
What this does and does not protect
It detects a checkpoint whose contents changed from what you reviewed — an upstream repository edit, a compromised mirror, a corrupted download, or a modified local copy.
It does not make an unreviewed checkpoint safe. A digest only says the bytes match what you recorded; deciding that those bytes are trustworthy is still yours.
Three limits worth knowing before you rely on it:
- Only listed files are checked. There is no “verify everything” mode and no way to reject a file you did not list, so an artifact missing from your map is loaded unverified. The map is the boundary of the guarantee.
- One parsed file is therefore outside it.
tokenizer/tokenizer_config.jsonis parsed, but Laya may normalise it and write it back immediately after the digest check, so pinning it can succeed on the first load and fail on the next. The recommended map leaves it out for that reason, which means its bytes are not verified. The rewrite is conditional on what the file declares, so whether it happens depends on the checkpoint. - Verification happens at load time only. Nothing re-checks a file afterwards, whether it is replaced by an attacker or by the process itself.