Skip to content

feat: resolve oci:// model references via llmman serve - #672

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

feat: resolve oci:// model references via llmman serve#672
ericcurtin wants to merge 1 commit into
michaelfeil:mainfrom
ericcurtin:feat/oci-modelpack-model

Conversation

@ericcurtin

Copy link
Copy Markdown

What

Adds an oci:// scheme so a model published as a CNCF ModelPack artifact can be used anywhere a Hugging Face repo id can:

infinity_emb v2 --model-id oci://ghcr.io/org/model:tag

Model distribution is increasingly moving to OCI registries -- the same registries, credentials, mirroring and air-gap tooling a deployment already uses for container images. Usually easier to run air-gapped than reaching the Hub.

How

EngineArgs.__post_init__ is the single dispatch point. Resolution runs first, so the rest of that method -- served_model_name derivation, vector_disk_cache_path, the loading strategy, and every engine downstream -- only ever sees a local path and needs no changes. Each engine then loads it exactly as it would a local directory.

One deliberate detail: served_model_name keeps the reference the user typed (oci://ghcr.io/org/model:tag) rather than the resolved store path, since the path is an implementation detail of llmman's cache. An explicitly-passed --served-model-name still wins.

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.

New infinity_emb/llmman.py is the daemon client, 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; --no-pull guarantees it only reports on what the pull already fetched, keeping the daemon the only thing that touches the network.
  • LLMMAN_HOST is honoured with llmman's own parsing, including rewriting a wildcard bind (0.0.0.0, [::]) to loopback.

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

Design notes

  • Explicit scheme, no sniffing. A bare registry/name:tag is indistinguishable from a Hugging Face repo id (michaelfeil/bge-small-en-v1.5); guessing would silently hijack existing --model-id org/model deployments. Every other id shape reaches exactly the branch it did before.
  • Tolerant parsing. A non-JSON diagnostic in the NDJSON stream is skipped rather than aborting a pull still in progress; the last non-empty line of resolve stdout is used; unknown JSON fields are ignored so the contract can grow.

Testing

Two new files under libs/infinity_emb/tests/unit_test/. test_llmman.py runs against a real HTTP server on a loopback port, not mocks, so the NDJSON contract is genuinely exercised.

$ pytest tests/unit_test/test_llmman.py tests/unit_test/test_oci.py
24 passed, 3 failed

24 passed, executed here. Coverage: scheme detection incl. case-insensitivity; that a HF repo id, a local path and s3:// are not claimed; strip_scheme round-trips; empty reference rejected; the bare reference handed to the daemon with progress wired; every LLMMAN_HOST form incl. wildcard-to-loopback; /api/version accepted / non-llmman rejected / nothing-listening actionable; pull success with byte progress and the exact request body; in-band error at HTTP 200; stream ending without success; non-OK status; non-JSON diagnostic tolerated.

3 failed here for an environment reason, not a defect -- the TestEngineArgsIntegration cases construct a real EngineArgs, which imports torch (ImportError: torch.nn is not available), unavailable in this environment. They assert that model_name_or_path is rewritten, that served_model_name keeps the typed reference, that an explicit served name wins, and that a plain HF repo id never reaches the resolver. They should pass in CI; flagging rather than quietly deleting them.

  • ruff format and ruff check clean on all four new/changed files. args.py reports 4 ruff findings both before and after this change (verified against a stashed clean tree), so none are introduced here. An earlier format run also touched tests/unit_test/inference/test_batch_handler.py; I reverted it so the diff stays scoped.
  • No end-to-end run against a live llmman serve backed by a real registry.

Disclosure: written with AI assistance.

Lets --model-id point at a model published as a CNCF ModelPack OCI
artifact:

    infinity_emb v2 --model-id 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,
and an error arriving in-band at HTTP 200 is caught) but deliberately
exposes no local path, so `llmman resolve --no-pull` reports where the
bytes landed. The client is stdlib-only, so no new dependency.

EngineArgs.__post_init__ is the single dispatch point, resolved first so
the rest of that method and the loading strategy only ever see a local
path -- every engine then loads it exactly as it would a local
directory. served_model_name keeps the reference the user typed rather
than the store path, unless one was given explicitly.

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>
@greptile-apps

greptile-apps Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds transparent resolution of oci:// model references through a running llmman daemon before engine configuration proceeds.

  • Preserves the user-entered OCI reference as the default served model name while replacing the loading path with llmman's local resolved path.
  • Introduces daemon probing, streamed ModelPack pulls, CLI-based local-path resolution, host parsing, and focused unit coverage.

Confidence Score: 2/5

The PR should not merge until HTTPS endpoint handling and bounded deadlines for both daemon pulls and CLI resolution prevent startup failures and indefinite hangs.

OCI acquisition runs synchronously during engine construction, while configured transport schemes are discarded and both long-running acquisition stages can block forever when their external counterpart stalls.

Files Needing Attention: libs/infinity_emb/infinity_emb/llmman.py

Important Files Changed

Filename Overview
libs/infinity_emb/infinity_emb/args.py Adds synchronous OCI resolution at the start of EngineArgs initialization while preserving the typed reference as the served name.
libs/infinity_emb/infinity_emb/llmman.py Implements daemon and CLI acquisition, but discards configured HTTPS schemes and leaves both pull streaming and resolver execution unbounded.
libs/infinity_emb/infinity_emb/oci.py Detects and strips the explicit OCI scheme, validates nonempty references, and delegates acquisition with progress logging.
libs/infinity_emb/tests/unit_test/test_llmman.py Covers daemon identity and NDJSON outcomes but does not exercise stalled operations or deadlines.
libs/infinity_emb/tests/unit_test/test_oci.py Covers scheme routing and common host forms but omits scheme-bearing LLMMAN_HOST values such as HTTPS.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI / Engine caller
    participant Args as EngineArgs
    participant OCI as oci.resolve
    participant Daemon as llmman daemon
    participant Bin as llmman CLI
    CLI->>Args: Construct with oci:// reference
    Args->>OCI: Resolve bare registry reference
    OCI->>Daemon: GET /api/version
    OCI->>Daemon: POST /api/pull (NDJSON)
    Daemon-->>OCI: progress and success
    OCI->>Bin: resolve --no-pull reference
    Bin-->>OCI: local model path
    OCI-->>Args: resolved path
    Args-->>CLI: configured local model
Loading

Reviews (1): Last reviewed commit: "feat: resolve oci:// model references vi..." | Re-trigger Greptile

Comment on lines +56 to +57
if "://" in raw:
raw = raw.split("://", 1)[1]

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.

P1 Configured HTTPS scheme discarded

If LLMMAN_HOST specifies an HTTPS daemon, endpoint() removes that scheme and unconditionally produces an http:// URL, causing OCI model initialization to fail against a TLS-only endpoint or silently dropping the configured transport protection.


succeeded = False
try:
with urllib.request.urlopen(req) as resp:

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.

P1 Model pull can hang startup

If the daemon accepts /api/pull but stalls its response or leaves the NDJSON stream open without a terminal status, this timeout-free urlopen and response loop block synchronous EngineArgs construction indefinitely, preventing the application server from completing startup.

Comment on lines +201 to +208
completed = subprocess.run(
[binary, "resolve", "--no-pull", reference],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
check=False,
)
if completed.returncode != 0:

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.

P1 Resolver can hang startup

If llmman resolve --no-pull hangs because of daemon, lock, or filesystem contention, this subprocess.run has no timeout and blocks synchronous model initialization indefinitely, so the application server never becomes ready or reports a resolver failure.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54737be9db

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

base = endpoint()
check_daemon(base)
logger.info("Pulling %s via llmman daemon at %s", reference, base)
pull(base, reference, progress)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check for the llmman binary before pulling

When the daemon is reachable but llmman is absent or INFINITY_LLMMAN_BIN is misconfigured, this starts and may complete a multi-gigabyte registry pull before resolve() performs the binary check and inevitably fails startup. Preflight the executable after probing the daemon but before beginning the expensive pull.

Useful? React with 👍 / 👎.

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.

1 participant