feat(oci): acquire CNCF ModelPack artifacts via llmman serve - #11788
feat(oci): acquire CNCF ModelPack artifacts via llmman serve#11788ericcurtin wants to merge 1 commit into
Conversation
The oci:// scheme extracts the reference as a container image: it writes the layers to a docker-save tar and unions that filesystem into the destination. A CNCF ModelPack artifact is not a runnable image. Its layers are typed application/vnd.cncf.model.*, an uncompressed one is a bare file rather than a tar, and there is no filesystem to union, so the existing path cannot read it. Detect a ModelPack manifest by its artifactType (falling back to the config descriptor's mediaType, for registries and clients that predate artifactType or strip it) and delegate acquisition to a running `llmman serve`, which already implements the ModelPack media types, registry auth, resumable blob download and a content-addressed store. That is registry protocol code LocalAI has no particular interest in owning, and it covers layouts a hand-rolled reader would have to grow one at a time. The daemon does the pull (POST /api/pull, streamed as NDJSON so a multi-gigabyte fetch is not silent, with status mapped onto the existing downloadStatus callback) but deliberately exposes no local path, so `llmman resolve --no-pull` reports where the bytes landed; --no-pull guarantees it only reports on what /api/pull already fetched. A pull therefore needs both the daemon reachable and the binary on PATH, and 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 rather than twice. Container images are untouched: a manifest that is not ModelPack takes exactly the path it did before, after signature verification as usual. Signed-off-by: Eric Curtin <eric.curtin@docker.com>
0c8db4b to
91750a1
Compare
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
Thanks for the detailed implementation and test notes. This needs a few repository-policy changes before it can merge:
-
pkg/llmman/client.goconstructs rawhttp.Clientvalues inDefaultClientandProbeClient. All outbound HTTP in LocalAI must usepkg/httpclient(httpclient.New(...)for the streaming pull andhttpclient.NewWithTimeout(...)for the version probe) so redirects and TLS use the project hardening policy. -
The new tests in
pkg/llmman/client_test.goandpkg/downloader/llmman_test.gouset.Fatal,t.Fatalf, andt.Errorf. New Go tests must use Ginkgo v2/Gomega; these calls are explicitly rejected by the repository lint policy. Please convert these cases to the existing package suites (or add a Ginkgo suite bootstrap where the package does not have one). -
This adds user-facing behavior and two configuration variables (
LLMMAN_HOSTandLOCALAI_LLMMAN_BIN) but no page underdocs/content/. The docs-with-code rule requires the setup, daemon/binary dependency, configuration, andoci://ModelPack example in this PR.
Once those are addressed, the protocol and filesystem behavior can get a focused follow-up review.
Description
The
oci://scheme extracts the reference as a container image:ExtractOCIImagewrites the layers to a docker-save tar and unions that filesystem into the destination. A CNCF ModelPack artifact is not a runnable image -- its layers are typedapplication/vnd.cncf.model.{weight,weight.config,doc,code,dataset}.v1.{raw,tar,tar+gzip,tar+zstd}, an uncompressed layer is a bare file rather than a tar, and there is no filesystem to union. Sooci://today works for a model baked into a container image, but not for a model artifact.Implementation
Detection stays local (
pkg/oci/modelpack.go):artifactType == application/vnd.cncf.model.manifest.v1+json, falling back to the config descriptor's mediaType for registries and clients that predate or stripartifactType.v1.Manifesthas noArtifactTypefield, so the raw manifest is parsed.Acquisition is delegated to a running
llmman serverather than hand-rolled here. llmman already implements the ModelPack media types, registry auth, resumable blob download and a content-addressed store -- that is registry protocol code LocalAI has no particular interest in owning, and it covers layouts a hand-rolled reader would have to grow one at a time (.rawvs.tarvs.tar+gzipvs.tar+zstd, filepath annotations, image indexes).New
pkg/llmmanis the client:GET /api/versionprobes reachability and identity -- a server answering without aversionfield is reported as "not an llmman daemon", which is worth distinguishing from nothing listening.POST /api/pullstreams NDJSON so a multi-gigabyte fetch is not silent; status/completed/totalmap onto the existingdownloadStatuscallback. An error arrives in-band at HTTP 200, and a stream that simply ends withoutsuccessis also a failure -- both are treated as errors rather than a completed pull.llmman resolve --no-pullreports where the bytes landed. The daemon deliberately exposes no local path (/api/showreturns only a digest and size), so the CLI is the documented interface for this;--no-pullguarantees it only reports on what/api/pullalready fetched, keeping the daemon the only thing that touches the network.So a pull needs both the daemon reachable and the binary on
PATH(orLOCALAI_LLMMAN_BIN); each missing piece has its own actionable error.LLMMAN_HOSTis honoured with the same parsing llmman's own clients use, including rewriting a wildcard bind (0.0.0.0,[::]) to loopback.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.
pkg/downloader/uri.gogains one branch after signature verification. A manifest that is not ModelPack takes exactly the path it did before, so container-image behaviour is bit-for-bit unchanged.Testing
Verified, actually executed:
pkg/llmman(13 tests, against a realhttptestserver -- no daemon needed): endpoint defaults and everyLLMMAN_HOSTform incl. wildcard-to-loopback rewriting;/api/versionaccepted, a non-llmman server rejected, nothing-listening reported actionably; pull success with forwarded byte progress; in-band error at HTTP 200; stream ending withoutsuccess; non-OK status; a non-JSON diagnostic line tolerated; the fullresolveoutput contract incl. leaked-diagnostic and unknown-field tolerance and six malformed cases;LOCALAI_LLMMAN_BINdefault/override/empty-override; missing-binary error.pkg/downloader(4 tests):linkOrCopyTreeover a nested directory, assertingst_inoequality to prove the file was hard-linked rather than copied; a single-file (GGUF) payload; overwriting a stale destination; a missing source.pkg/oci(5 specs): detection byartifactType, by config mediaType, a plain container image not claimed, neither-discriminator not claimed, unparseable manifest errors.go build ./pkg/...,go vet,gofmt -lall clean on touched files (note:pkg/downloader/cancel_test.gois unformatted onmasteralready; untouched here)Not verified here, flagged rather than implied: no run against a real registry serving a ModelPack artifact, and no end-to-end model load against a live
llmman serve. Coverage is at the client/protocol level with a stub server.Notes for Reviewers
Two judgement calls worth a look:
llmman serveowns the download but exposes no path;llmman resolveknows the path but would pull in-process. Using/api/pull+resolve --no-pullkeeps all network I/O in the daemon (one shared cache, one place to configure registry credentials) while still learning where the files are. The cost is two dependencies instead of one, which is why each has a distinct error message.[X] Yes, I signed my commits.