Skip to content

feat: support oci:// model source via llmman serve - #521

Open
ericcurtin wants to merge 1 commit into
InftyAI:mainfrom
ericcurtin:feat/oci-modelpack-source
Open

feat: support oci:// model source via llmman serve#521
ericcurtin wants to merge 1 commit into
InftyAI:mainfrom
ericcurtin:feat/oci-modelpack-source

Conversation

@ericcurtin

@ericcurtin ericcurtin commented Aug 30, 2026

Copy link
Copy Markdown

What this PR does / why we need it

ModelSource.URI supports OSS, S3, GCS, OLLAMA and HOST; anything else hits panic("protocol not supported"). This adds OCI, so a model published as a CNCF ModelPack artifact can be a model source:

apiVersion: llmaz.io/v1alpha1
kind: OpenModel
spec:
  source:
    uri: oci://ghcr.io/org/model:tag

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/)

  • New OCI protocol; NewModelSourceProvider stores the whole address as the reference (no bucket/endpoint to split).
  • InjectModelLoader injects MODEL_SOURCE_TYPE=oci, the scheme-stripped OCI_REFERENCE, and LLMMAN_HOST -- the daemon the loader pulls through. It defaults to llmman's own default (127.0.0.1:17434); setting LLMAZ_LLMMAN_HOST on the controller points every loader at one shared daemon instead.
  • ModelPath returns /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 derive models--<name> or a .gguf filename from. This also avoids a tag like :v1.2.gguf being read as a GGUF filename by the existing strings.Contains(".gguf") check.
  • Webhook: OCI added to SUPPORTED_OBJ_STORES.

Loader (llmaz/model_loader/oci/)

Acquisition is delegated to a running llmman serve rather 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+zstd layouts, filepath annotations and image indexes.

The client is stdlib-only (urllib), so no new Python dependency:

  • GET /api/version probes reachability and identity -- a server answering without a version field is reported as "not an llmman daemon", worth distinguishing from nothing listening.
  • POST /api/pull streams 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 without success is also a failure -- both are errors, not a completed pull.
  • llmman resolve --no-pull reports where the bytes landed. The daemon deliberately exposes no local path, so the CLI is the documented interface; --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 (or LLMAZ_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 TestModelSourceProvider table (OCI path, skipModelLoader, and a dotted tag not read as GGUF), plus TestOCIInjectModelLoader asserting the injected env incl. the daemon address, and TestOCILlmmanHostIsOverridable:

$ go test ./pkg/...
ok  github.com/inftyai/llmaz/pkg/controller_helper/modelsource
(4 packages ok, 0 failures)

Python -- 36 cases:

$ pytest llmaz/tests/test_oci_loader.py llmaz/tests/test_llmman.py
36 passed

test_llmman.py (29) runs against a real HTTP server on a loopback port, not mocks, so the NDJSON streaming contract is genuinely exercised: every LLMMAN_HOST form incl. wildcard-to-loopback rewriting; /api/version accepted, 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 without success; 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, and materialize: hard-linking asserted via st_ino equality, a single-file (GGUF) payload, a stale destination overwritten, and the copy fallback when os.link raises EXDEV (asserting the inodes then differ).

  • gofmt -l pkg/ clean, go vet clean, black clean
  • Diff to existing files is additive -- no behaviour removed from any other protocol

Not 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.py could not be collected in this environment (missing modelscope), unrelated.

Docs: docs/examples/oci/ with a Playground example and daemon configuration.

Disclosure: this change was written with AI assistance.

@InftyAI-Agent InftyAI-Agent added needs-triage Indicates an issue or PR lacks a label and requires one. needs-priority Indicates a PR lacks a label and requires one. do-not-merge/needs-kind Indicates a PR lacks a label and requires one. labels Aug 30, 2026
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
ericcurtin force-pushed the feat/oci-modelpack-source branch from 9029be1 to 2c95fbe Compare August 30, 2026 21:31
@ericcurtin ericcurtin changed the title feat: support oci:// model source for CNCF ModelPack artifacts feat: support oci:// model source via llmman serve Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge/needs-kind Indicates a PR lacks a label and requires one. needs-priority Indicates a PR lacks a label and requires one. needs-triage Indicates an issue or PR lacks a label and requires one.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants