feat: support oci:// model source via llmman serve - #521
Open
ericcurtin wants to merge 1 commit into
Open
Conversation
Adds OCI as a URI protocol so a model published as a CNCF ModelPack
artifact can be a model source:
source:
uri: oci://ghcr.io/org/model:tag
This reuses the registry, credentials and mirroring a cluster already
has for container images, which is often easier to run air-gapped than
a model hub.
Controller side: a new OCI protocol on URIProvider, injecting
MODEL_SOURCE_TYPE=oci, the scheme-stripped reference, and the address of
the llmman daemon the loader pulls through (LLMAZ_LLMMAN_HOST on the
controller points every loader at one shared daemon). ModelPath returns
the model directory itself: an OCI artifact unpacks as a whole and its
layer filepaths already name the files, so there is no bucket key to
derive models--<name> or a .gguf filename from.
Loader side: a new llmaz.model_loader.oci module that delegates to a
running `llmman serve`, which already implements the ModelPack media
types, registry auth, resumable blob download and a content-addressed
store. The daemon does the pull (POST /api/pull, streamed as NDJSON so a
multi-gigabyte fetch is not silent) but deliberately exposes no local
path, so `llmman resolve --no-pull` reports where the bytes landed. The
client is stdlib-only, so no new Python dependency.
Files are hard-linked out of llmman's store where possible, falling back
to a copy across filesystems, so a model shared with llmman costs its
bytes once rather than twice.
Registry credentials live on the daemon rather than being injected per
model, so one place covers every model pulled through it.
Signed-off-by: Eric Curtin <eric.curtin@docker.com>
ericcurtin
force-pushed
the
feat/oci-modelpack-source
branch
from
August 30, 2026 21:31
9029be1 to
2c95fbe
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does / why we need it
ModelSource.URIsupportsOSS,S3,GCS,OLLAMAandHOST; anything else hitspanic("protocol not supported"). This addsOCI, so a model published as a CNCF ModelPack artifact can be a model source:Model distribution is increasingly moving to OCI registries, which lets a cluster reuse the registry, credentials and mirroring it already has for container images -- usually easier to run air-gapped than a model hub.
Implementation
Controller (
pkg/controller_helper/modelsource/)OCIprotocol;NewModelSourceProviderstores the whole address as the reference (no bucket/endpoint to split).InjectModelLoaderinjectsMODEL_SOURCE_TYPE=oci, the scheme-strippedOCI_REFERENCE, andLLMMAN_HOST-- the daemon the loader pulls through. It defaults to llmman's own default (127.0.0.1:17434); settingLLMAZ_LLMMAN_HOSTon the controller points every loader at one shared daemon instead.ModelPathreturns/workspace/models/for OCI. An artifact unpacks as a whole and its layer filepaths already name the files, so there is no bucket key to derivemodels--<name>or a.gguffilename from. This also avoids a tag like:v1.2.ggufbeing read as a GGUF filename by the existingstrings.Contains(".gguf")check.OCIadded toSUPPORTED_OBJ_STORES.Loader (
llmaz/model_loader/oci/)Acquisition is delegated to a running
llmman serverather than hand-rolled. llmman already implements the ModelPack media types, registry auth, resumable blob download and a content-addressed store -- registry protocol code llmaz has no particular interest in owning, covering.raw/.tar/.tar+gzip/.tar+zstdlayouts, filepath annotations and image indexes.The client is stdlib-only (
urllib), so no new Python dependency:GET /api/versionprobes reachability and identity -- a server answering without aversionfield is reported as "not an llmman daemon", worth distinguishing from nothing listening.POST /api/pullstreams NDJSON so a multi-gigabyte fetch is not silent, with status forwarded to the loader log. An error arrives in-band at HTTP 200, and a stream that ends withoutsuccessis also a failure -- both are errors, not a completed pull.llmman resolve --no-pullreports where the bytes landed. The daemon deliberately exposes no local path, so the CLI is the documented interface;--no-pullguarantees it only reports on what/api/pullalready fetched.A pull therefore needs both the daemon reachable and the binary on
PATH(orLLMAZ_LLMMAN_BIN); each missing piece has its own actionable error.Files are hard-linked out of llmman's store where possible, falling back to a copy across filesystems, so a model shared with llmman costs its bytes once.
Registry credentials live on the daemon, not on the model -- one place covers every model pulled through it, rather than a Secret per namespace.
Testing
Both suites actually executed.
Go -- 3 cases added to the
TestModelSourceProvidertable (OCI path,skipModelLoader, and a dotted tag not read as GGUF), plusTestOCIInjectModelLoaderasserting the injected env incl. the daemon address, andTestOCILlmmanHostIsOverridable:Python -- 36 cases:
test_llmman.py(29) runs against a real HTTP server on a loopback port, not mocks, so the NDJSON streaming contract is genuinely exercised: everyLLMMAN_HOSTform incl. wildcard-to-loopback rewriting;/api/versionaccepted, a non-llmman server rejected, nothing-listening reported actionably; pull success with forwarded byte progress and the exact request body asserted; in-band error at HTTP 200; a stream ending withoutsuccess; non-OK status; a non-JSON diagnostic tolerated; the full resolve contract plus eight malformed-output cases; binary default/override/empty-override; missing-binary error.test_oci_loader.py(7) covers the empty reference, that the daemon receives the bare reference with progress wired, andmaterialize: hard-linking asserted viast_inoequality, a single-file (GGUF) payload, a stale destination overwritten, and the copy fallback whenos.linkraisesEXDEV(asserting the inodes then differ).gofmt -l pkg/clean,go vetclean,blackcleanNot verified here, flagged rather than implied: no live cluster run, and no pull against a real registry through a live
llmman serve;llmaz/tests/test_hub_factory.pycould not be collected in this environment (missingmodelscope), unrelated.Docs:
docs/examples/oci/with a Playground example and daemon configuration.