Skip to content

feat: resolve oci:// model paths via llmman serve - #4922

Open
ericcurtin wants to merge 1 commit into
InternLM:mainfrom
ericcurtin:feat/oci-modelpack-model-path
Open

feat: resolve oci:// model paths via llmman serve#4922
ericcurtin wants to merge 1 commit into
InternLM:mainfrom
ericcurtin:feat/oci-modelpack-model-path

Conversation

@ericcurtin

@ericcurtin ericcurtin commented Aug 30, 2026

Copy link
Copy Markdown

Motivation

Model distribution is increasingly moving to OCI registries -- the same registries, credentials, mirroring and air-gap tooling a deployment already uses for container images. CNCF ModelPack is the spec for that.

This adds an oci:// scheme so such a model works anywhere a HuggingFace repo id does:

lmdeploy serve api_server oci://ghcr.io/org/model:tag
lmdeploy chat oci://ghcr.io/org/model:tag

Modification

  • lmdeploy/llmman.py (new): a client for a running llmman serve daemon. Acquisition is delegated rather than hand-rolled -- llmman already implements the ModelPack media types, registry auth, resumable blob download and a content-addressed store. Stdlib-only (urllib), no new 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. 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.
    • LLMMAN_HOST is honoured with llmman's own parsing, including rewriting a wildcard bind (0.0.0.0, [::]) to loopback.
  • lmdeploy/oci.py (new): scheme handling. Kept free of heavy imports and using plain logging rather than lmdeploy.utils.get_logger, since utils imports this module from get_model().

  • lmdeploy/utils.py: get_model() is the single dispatch point -- the CLI, vl/model/builder.py and turbomind.py all route through it, so one branch covers every entry point. download_dir / revision / token are hub concepts with no ModelPack counterpart and are deliberately not forwarded.

  • lmdeploy/cli/entrypoint.py: model_path_or_server treats any : as marking a host:port server address. An oci://ghcr.io/org/model:tag carries one in its tag and would be misread as a server, so the scheme is matched before that check. model_path needed no change -- an oci:// reference never satisfies os.path.exists, so it already falls through to get_model.

A pull needs both the daemon reachable and the binary on PATH (or LMDEPLOY_LLMMAN_BIN); each missing piece has its own actionable error. Neither is required unless an oci:// reference is actually used.

BC-breaking

No. Purely additive; the ModelScope / openmind_hub / HuggingFace branches are unchanged.

Use cases

Pulling model weights in an air-gapped or registry-mirrored cluster without needing HuggingFace reachable, using the credentials already configured for container images. One daemon can serve many lmdeploy processes from a single content-addressed store.

Checklist

  • Pre-commit or other linting tools are used to fix the potential lint issues -- ruff format and ruff check clean on all six touched files.
  • The modification is covered by complete unit tests.
  • If this PR introduces a new feature, docs have been added.

Testing

Two new files. tests/test_lmdeploy/test_llmman.py (8 cases) runs against a real HTTP server on a loopback port, not mocks, so the NDJSON streaming contract is genuinely exercised: /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.

tests/test_lmdeploy/test_oci.py (14 cases): scheme detection incl. case-insensitivity; that a HF repo id, a local path, s3:// and an http://host:port server address are not claimed; strip_scheme round-trips; the resolve contract plus eight malformed-output cases; every LLMMAN_HOST form incl. wildcard-to-loopback; binary default/override and the missing-binary error; empty reference rejected without touching the daemon; the scheme stripped before hand-off with progress wired; and two dispatch tests asserting oci:// never reaches snapshot_download while a HF repo id still does.

Verified honestly:

  • 19 logic assertions executed standalone against a live loopback daemon (scheme handling, endpoint parsing incl. wildcard rewriting, /api/version accept/reject, pull success + progress + request body, in-band error, resolve contract) -- all pass
  • The two pytest files were not executed in-tree: importing lmdeploy.utils needs torch and the full stack, unavailable here. The two TestGetModelDispatch cases in particular are unexercised. Flagging rather than implying coverage I do not have.
  • No end-to-end serve against a live llmman serve backed by a real registry.

Copilot AI lite review requested due to automatic review settings August 30, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class support for resolving oci:// model references (CNCF ModelPack artifacts in OCI registries) into a local directory path, integrating resolution into the existing get_model() dispatch so it works across LMDeploy entry points. It delegates registry/auth/download behavior to the external llmman CLI and adjusts CLI argument handling to avoid misclassifying OCI tags as host:port server addresses.

Changes:

  • Introduce lmdeploy/oci.py to detect oci:// references and resolve them via llmman resolve, parsing a JSON stdout contract.
  • Route oci://... through get_model() in lmdeploy/utils.py so all callers gain OCI support.
  • Update CLI entrypoint parsing to treat oci://...:tag as a model reference (not a server), and add unit tests covering scheme detection and resolver behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/test_lmdeploy/test_oci.py Adds unit tests for oci:// scheme detection, llmman output parsing, and get_model() dispatch behavior.
lmdeploy/utils.py Adds an OCI branch to get_model() to resolve oci:// references via lmdeploy.oci.
lmdeploy/oci.py New module implementing oci:// detection, scheme stripping, llmman invocation, and stdout JSON parsing.
lmdeploy/cli/entrypoint.py Adjusts model_path_or_server handling so oci://...:tag isn’t treated as host:port.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lmdeploy/oci.py
Comment on lines +45 to +48
text = str(model_path)
if is_oci_ref(text):
return text[len(SCHEME) :]
return text
Comment thread lmdeploy/oci.py Outdated
Comment on lines +74 to +81
path = payload.get("path")
if not isinstance(path, str) or not path.strip():
raise RuntimeError(f"llmman resolve {reference!r}: returned an empty path")

if not os.path.exists(path):
raise RuntimeError(f"llmman resolve {reference!r}: reported path {path!r} does not exist")

return path
Comment thread lmdeploy/oci.py Outdated
Comment on lines +91 to +103
binary = _llmman_bin()
if shutil.which(binary) is None and not os.path.isfile(binary):
raise RuntimeError(
f"{binary!r} not found. Install llmman "
"(https://github.com/llmmanorg/llmman) and put it on PATH, or set "
f"{_BIN_ENV} to its location, to use oci:// model paths."
)

# stderr is inherited so pull progress reaches the terminal; only stdout
# carries the contract.
completed = subprocess.run(
[binary, "resolve", reference], stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, check=False
)
Lets a model path point at a model published as a CNCF ModelPack OCI
artifact, anywhere a HuggingFace repo id works today:

    lmdeploy serve api_server oci://ghcr.io/org/model:tag

Model distribution is increasingly moving to OCI registries, which lets
a deployment reuse the registry, credentials, mirroring and air-gap
tooling it already has for container images.

Acquisition is delegated 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; --no-pull guarantees it only reports on
what /api/pull already fetched. The client is stdlib-only, so no new
dependency.

get_model() is the single dispatch point, so every caller (CLI, vl
builder, turbomind) gets this at once. download_dir/revision/token are
hub concepts with no ModelPack counterpart and are not forwarded.

entrypoint.py also needed a small fix: model_path_or_server treats any
':' as marking a host:port server address, which an oci:// tag would trip
over, so the scheme is matched before that check.

An explicit oci:// scheme is required rather than sniffing a bare
registry/name:tag: that shape is indistinguishable from a HuggingFace
repo id, so guessing would silently hijack existing deployments.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
@ericcurtin
ericcurtin force-pushed the feat/oci-modelpack-model-path branch from 5db75a1 to 891aa7d Compare August 30, 2026 21:35
@ericcurtin ericcurtin changed the title feat: resolve oci:// model paths as CNCF ModelPack artifacts feat: resolve oci:// model paths 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants