From f3c3548d6f2a2a40284533a6fe496be567af6a47 Mon Sep 17 00:00:00 2001 From: Rishi Jat Date: Fri, 13 Mar 2026 13:35:48 +0530 Subject: [PATCH 1/3] test: add E2E validation for ModelPack compatibility Signed-off-by: Rishi Jat --- README.md | 23 ++ pkg/distribution/distribution/client_test.go | 208 +++++++++++++++++++ 2 files changed, 231 insertions(+) diff --git a/README.md b/README.md index 56b428503..21aa997e7 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,29 @@ MODEL_RUNNER_HOST=http://localhost:13434 ./model-cli list - [Model Specification](https://github.com/docker/model-spec/blob/main/spec.md) - [Community Slack Channel](https://dockercommunity.slack.com/archives/C09H9P5E57B) +### ModelPack Compatibility + +Docker Model Runner supports both Docker model-spec artifacts and CNCF ModelPack artifacts stored in OCI registries. + +For ModelPack images, Docker Model Runner accepts: + +- config media type: `application/vnd.cncf.model.config.v1+json` +- weight layer media types, including: + - `application/vnd.cncf.model.weight.v1.gguf` + - `application/vnd.cncf.model.weight.v1.safetensors` + +This means you can pull and run a ModelPack artifact with the same user workflow: + +```bash +# Pull from any OCI-compliant registry +docker model pull //: + +# Run the model +docker model run //: "Hello" +``` + +If you are publishing artifacts for compatibility across tooling, ensure your image config and layer media types follow the ModelPack spec so downstream clients can detect and use the correct format. + ## Using the Makefile This project includes a Makefile to simplify common development tasks. Docker targets require Docker Desktop >= 4.41.0. diff --git a/pkg/distribution/distribution/client_test.go b/pkg/distribution/distribution/client_test.go index c314dc08d..c5c030cd1 100644 --- a/pkg/distribution/distribution/client_test.go +++ b/pkg/distribution/distribution/client_test.go @@ -15,22 +15,164 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/docker/model-runner/pkg/distribution/internal/mutate" + "github.com/docker/model-runner/pkg/distribution/internal/partial" "github.com/docker/model-runner/pkg/distribution/internal/progress" "github.com/docker/model-runner/pkg/distribution/internal/testutil" + "github.com/docker/model-runner/pkg/distribution/modelpack" "github.com/docker/model-runner/pkg/distribution/oci" "github.com/docker/model-runner/pkg/distribution/oci/reference" "github.com/docker/model-runner/pkg/distribution/oci/remote" mdregistry "github.com/docker/model-runner/pkg/distribution/registry" "github.com/docker/model-runner/pkg/distribution/registry/testregistry" + "github.com/docker/model-runner/pkg/distribution/types" "github.com/docker/model-runner/pkg/inference/platform" + "github.com/opencontainers/go-digest" ) var ( testGGUFFile = filepath.Join("..", "assets", "dummy.gguf") ) +type modelPackTestArtifact struct { + rawConfig []byte + layers []oci.Layer +} + +func (m *modelPackTestArtifact) Layers() ([]oci.Layer, error) { + return m.layers, nil +} + +func (m *modelPackTestArtifact) MediaType() (oci.MediaType, error) { + manifest, err := m.Manifest() + if err != nil { + return "", err + } + return manifest.MediaType, nil +} + +func (m *modelPackTestArtifact) Size() (int64, error) { + rawManifest, err := m.RawManifest() + if err != nil { + return 0, err + } + size := int64(len(rawManifest) + len(m.rawConfig)) + for _, layer := range m.layers { + layerSize, err := layer.Size() + if err != nil { + return 0, err + } + size += layerSize + } + return size, nil +} + +func (m *modelPackTestArtifact) ConfigName() (oci.Hash, error) { + hash, _, err := oci.SHA256(bytes.NewReader(m.rawConfig)) + return hash, err +} + +func (m *modelPackTestArtifact) ConfigFile() (*oci.ConfigFile, error) { + return nil, errors.New("invalid for model") +} + +func (m *modelPackTestArtifact) RawConfigFile() ([]byte, error) { + return m.rawConfig, nil +} + +func (m *modelPackTestArtifact) Digest() (oci.Hash, error) { + rawManifest, err := m.RawManifest() + if err != nil { + return oci.Hash{}, err + } + hash, _, err := oci.SHA256(bytes.NewReader(rawManifest)) + return hash, err +} + +func (m *modelPackTestArtifact) Manifest() (*oci.Manifest, error) { + return partial.ManifestForLayers(m) +} + +func (m *modelPackTestArtifact) RawManifest() ([]byte, error) { + manifest, err := m.Manifest() + if err != nil { + return nil, err + } + return json.Marshal(manifest) +} + +func (m *modelPackTestArtifact) LayerByDigest(hash oci.Hash) (oci.Layer, error) { + for _, layer := range m.layers { + layerDigest, err := layer.Digest() + if err != nil { + return nil, err + } + if layerDigest == hash { + return layer, nil + } + } + return nil, fmt.Errorf("layer with digest %s not found", hash) +} + +func (m *modelPackTestArtifact) LayerByDiffID(hash oci.Hash) (oci.Layer, error) { + for _, layer := range m.layers { + layerDiffID, err := layer.DiffID() + if err != nil { + return nil, err + } + if layerDiffID == hash { + return layer, nil + } + } + return nil, fmt.Errorf("layer with diffID %s not found", hash) +} + +func (m *modelPackTestArtifact) GetConfigMediaType() oci.MediaType { + return types.MediaTypeModelConfigV02 +} + +func newModelPackTestArtifact(t *testing.T, modelFile string) *modelPackTestArtifact { + t.Helper() + + layer, err := partial.NewLayer(modelFile, oci.MediaType(modelpack.MediaTypeWeightGGUF)) + if err != nil { + t.Fatalf("Failed to create ModelPack layer: %v", err) + } + + diffID, err := layer.DiffID() + if err != nil { + t.Fatalf("Failed to get layer DiffID: %v", err) + } + + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + mp := modelpack.Model{ + Descriptor: modelpack.ModelDescriptor{ + CreatedAt: &now, + Name: "dummy-modelpack", + }, + Config: modelpack.ModelConfig{ + Format: "gguf", + ParamSize: "8B", + }, + ModelFS: modelpack.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{digest.Digest(diffID.String())}, + }, + } + + rawConfig, err := json.Marshal(mp) + if err != nil { + t.Fatalf("Failed to marshal ModelPack config: %v", err) + } + + return &modelPackTestArtifact{ + rawConfig: rawConfig, + layers: []oci.Layer{layer}, + } +} + // newTestClient creates a new client configured for testing with plain HTTP enabled. func newTestClient(storeRootPath string) (*Client, error) { return NewClient( @@ -142,6 +284,72 @@ func TestClientPullModel(t *testing.T) { } }) + t.Run("pull modelpack artifact", func(t *testing.T) { + tempDir := t.TempDir() + + testClient, err := newTestClient(tempDir) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + tag := registryHost + "/modelpack-test/model:v1.0.0" + ref, err := reference.ParseReference(tag) + if err != nil { + t.Fatalf("Failed to parse reference: %v", err) + } + + mpModel := newModelPackTestArtifact(t, testGGUFFile) + if err := remote.Write(ref, mpModel, nil, remote.WithPlainHTTP(true)); err != nil { + t.Fatalf("Failed to push ModelPack model: %v", err) + } + + if err := testClient.PullModel(t.Context(), tag, nil); err != nil { + t.Fatalf("Failed to pull ModelPack model: %v", err) + } + + pulledModel, err := testClient.GetModel(tag) + if err != nil { + t.Fatalf("Failed to get pulled model: %v", err) + } + + ggufPaths, err := pulledModel.GGUFPaths() + if err != nil { + t.Fatalf("Failed to get GGUF paths: %v", err) + } + if len(ggufPaths) != 1 { + t.Fatalf("Unexpected number of GGUF files: %d", len(ggufPaths)) + } + + pulledContent, err := os.ReadFile(ggufPaths[0]) + if err != nil { + t.Fatalf("Failed to read pulled GGUF file: %v", err) + } + + originalContent, err := os.ReadFile(testGGUFFile) + if err != nil { + t.Fatalf("Failed to read source GGUF file: %v", err) + } + + if string(pulledContent) != string(originalContent) { + t.Errorf("Pulled ModelPack model content doesn't match original") + } + + cfg, err := pulledModel.Config() + if err != nil { + t.Fatalf("Failed to read pulled model config: %v", err) + } + if cfg.GetFormat() != "gguf" { + t.Errorf("Config format = %q, want %q", cfg.GetFormat(), "gguf") + } + if cfg.GetParameters() != "8B" { + t.Errorf("Config parameters = %q, want %q", cfg.GetParameters(), "8B") + } + + if _, ok := cfg.(*modelpack.Model); !ok { + t.Errorf("Config type = %T, want *modelpack.Model", cfg) + } + }) + t.Run("pull non-existent model", func(t *testing.T) { tempDir := t.TempDir() From 2e9c41fa076c70633afb9cadf41bce1280dc3dae Mon Sep 17 00:00:00 2001 From: Rishi Jat Date: Fri, 13 Mar 2026 18:57:25 +0530 Subject: [PATCH 2/3] copilot suggestion and fix ci Signed-off-by: Rishi Jat --- pkg/distribution/distribution/bundle_test.go | 5 ++-- pkg/distribution/distribution/client.go | 5 +++- pkg/distribution/distribution/client_test.go | 31 ++++++++++---------- pkg/distribution/distribution/ecr_test.go | 5 ++-- pkg/distribution/distribution/gar_test.go | 5 ++-- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/pkg/distribution/distribution/bundle_test.go b/pkg/distribution/distribution/bundle_test.go index cecb3eeed..6e0d624d2 100644 --- a/pkg/distribution/distribution/bundle_test.go +++ b/pkg/distribution/distribution/bundle_test.go @@ -1,6 +1,7 @@ package distribution import ( + "bytes" "errors" "os" "path/filepath" @@ -142,8 +143,8 @@ func TestBundle(t *testing.T) { if err != nil { t.Fatalf("Failed to read file with expected contents: %v", err) } - if string(got) != string(expected) { - t.Fatalf("File contents did not match expected contents. Expected: %s, got: %s", expected, got) + if !bytes.Equal(got, expected) { + t.Fatalf("File contents did not match expected contents") } } }) diff --git a/pkg/distribution/distribution/client.go b/pkg/distribution/distribution/client.go index c25945432..50559f27f 100644 --- a/pkg/distribution/distribution/client.go +++ b/pkg/distribution/distribution/client.go @@ -16,6 +16,7 @@ import ( "github.com/docker/model-runner/pkg/distribution/internal/mutate" "github.com/docker/model-runner/pkg/distribution/internal/progress" "github.com/docker/model-runner/pkg/distribution/internal/store" + "github.com/docker/model-runner/pkg/distribution/modelpack" "github.com/docker/model-runner/pkg/distribution/oci" "github.com/docker/model-runner/pkg/distribution/oci/authn" "github.com/docker/model-runner/pkg/distribution/oci/remote" @@ -786,7 +787,9 @@ func checkCompat(image types.ModelArtifact, log *slog.Logger, reference string, if err != nil { return err } - if manifest.Config.MediaType != types.MediaTypeModelConfigV01 && manifest.Config.MediaType != types.MediaTypeModelConfigV02 { + if manifest.Config.MediaType != types.MediaTypeModelConfigV01 && + manifest.Config.MediaType != types.MediaTypeModelConfigV02 && + manifest.Config.MediaType != oci.MediaType(modelpack.MediaTypeModelConfigV1) { return fmt.Errorf("config type %q is unsupported: %w", manifest.Config.MediaType, ErrUnsupportedMediaType) } diff --git a/pkg/distribution/distribution/client_test.go b/pkg/distribution/distribution/client_test.go index c5c030cd1..948bbb0bb 100644 --- a/pkg/distribution/distribution/client_test.go +++ b/pkg/distribution/distribution/client_test.go @@ -27,7 +27,6 @@ import ( "github.com/docker/model-runner/pkg/distribution/oci/remote" mdregistry "github.com/docker/model-runner/pkg/distribution/registry" "github.com/docker/model-runner/pkg/distribution/registry/testregistry" - "github.com/docker/model-runner/pkg/distribution/types" "github.com/docker/model-runner/pkg/inference/platform" "github.com/opencontainers/go-digest" ) @@ -130,7 +129,7 @@ func (m *modelPackTestArtifact) LayerByDiffID(hash oci.Hash) (oci.Layer, error) } func (m *modelPackTestArtifact) GetConfigMediaType() oci.MediaType { - return types.MediaTypeModelConfigV02 + return oci.MediaType(modelpack.MediaTypeModelConfigV1) } func newModelPackTestArtifact(t *testing.T, modelFile string) *modelPackTestArtifact { @@ -240,8 +239,8 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(modelContent) { - t.Errorf("Pulled model content doesn't match original: got %q, want %q", pulledContent, modelContent) + if !bytes.Equal(pulledContent, modelContent) { + t.Errorf("Pulled model content doesn't match original") } }) @@ -279,8 +278,8 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(modelContent) { - t.Errorf("Pulled model content doesn't match original: got %q, want %q", pulledContent, modelContent) + if !bytes.Equal(pulledContent, modelContent) { + t.Errorf("Pulled model content doesn't match original") } }) @@ -292,8 +291,8 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to create client: %v", err) } - tag := registryHost + "/modelpack-test/model:v1.0.0" - ref, err := reference.ParseReference(tag) + mpTag := registryHost + "/modelpack-test/model:v1.0.0" + ref, err := reference.ParseReference(mpTag) if err != nil { t.Fatalf("Failed to parse reference: %v", err) } @@ -303,11 +302,11 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to push ModelPack model: %v", err) } - if err := testClient.PullModel(t.Context(), tag, nil); err != nil { + if err := testClient.PullModel(t.Context(), mpTag, nil); err != nil { t.Fatalf("Failed to pull ModelPack model: %v", err) } - pulledModel, err := testClient.GetModel(tag) + pulledModel, err := testClient.GetModel(mpTag) if err != nil { t.Fatalf("Failed to get pulled model: %v", err) } @@ -330,7 +329,7 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read source GGUF file: %v", err) } - if string(pulledContent) != string(originalContent) { + if !bytes.Equal(pulledContent, originalContent) { t.Errorf("Pulled ModelPack model content doesn't match original") } @@ -540,8 +539,8 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(testModelContent) { - t.Errorf("Pulled model content doesn't match original: got %q, want %q", pulledContent, testModelContent) + if !bytes.Equal(pulledContent, testModelContent) { + t.Errorf("Pulled model content doesn't match original") } // Create a modified version of the model @@ -590,8 +589,8 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read updated pulled model: %v", err) } - if string(updatedPulledContent) != string(updatedContent) { - t.Errorf("Updated pulled model content doesn't match: got %q, want %q", updatedPulledContent, updatedContent) + if !bytes.Equal(updatedPulledContent, updatedContent) { + t.Errorf("Updated pulled model content doesn't match") } }) @@ -734,7 +733,7 @@ func TestClientPullModel(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(modelContent) { + if !bytes.Equal(pulledContent, modelContent) { t.Errorf("Pulled model content doesn't match original") } }) diff --git a/pkg/distribution/distribution/ecr_test.go b/pkg/distribution/distribution/ecr_test.go index 699b8dbb6..66547d3f9 100644 --- a/pkg/distribution/distribution/ecr_test.go +++ b/pkg/distribution/distribution/ecr_test.go @@ -1,6 +1,7 @@ package distribution import ( + "bytes" "os" "testing" @@ -79,8 +80,8 @@ func TestECRIntegration(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(modelContent) { - t.Errorf("Pulled model content doesn't match original: got %q, want %q", pulledContent, modelContent) + if !bytes.Equal(pulledContent, modelContent) { + t.Errorf("Pulled model content doesn't match original") } }) diff --git a/pkg/distribution/distribution/gar_test.go b/pkg/distribution/distribution/gar_test.go index 669e10c12..b92665fe1 100644 --- a/pkg/distribution/distribution/gar_test.go +++ b/pkg/distribution/distribution/gar_test.go @@ -1,6 +1,7 @@ package distribution import ( + "bytes" "os" "testing" @@ -80,8 +81,8 @@ func TestGARIntegration(t *testing.T) { t.Fatalf("Failed to read pulled model: %v", err) } - if string(pulledContent) != string(modelContent) { - t.Errorf("Pulled model content doesn't match original: got %q, want %q", pulledContent, modelContent) + if !bytes.Equal(pulledContent, modelContent) { + t.Errorf("Pulled model content doesn't match original") } }) From 7e14c8f2c39ccce1da54a221d58a660c29f87173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Mon, 23 Mar 2026 09:59:47 +0100 Subject: [PATCH 3/3] enhance ModelPack support for format-agnostic weight media types --- README.md | 1 + pkg/distribution/distribution/client_test.go | 74 ++++++++++++++++++- pkg/distribution/internal/partial/partial.go | 63 +++++++++++----- .../internal/partial/partial_test.go | 30 ++++++++ pkg/distribution/modelpack/convert.go | 19 ++++- pkg/distribution/modelpack/types.go | 23 ++++++ 6 files changed, 190 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 21aa997e7..79d14b2a2 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ For ModelPack images, Docker Model Runner accepts: - config media type: `application/vnd.cncf.model.config.v1+json` - weight layer media types, including: + - `application/vnd.cncf.model.weight.v1.raw` (uncompressed, used by [modctl](https://github.com/modelpack/modctl)) - `application/vnd.cncf.model.weight.v1.gguf` - `application/vnd.cncf.model.weight.v1.safetensors` diff --git a/pkg/distribution/distribution/client_test.go b/pkg/distribution/distribution/client_test.go index 948bbb0bb..f31287abd 100644 --- a/pkg/distribution/distribution/client_test.go +++ b/pkg/distribution/distribution/client_test.go @@ -132,10 +132,11 @@ func (m *modelPackTestArtifact) GetConfigMediaType() oci.MediaType { return oci.MediaType(modelpack.MediaTypeModelConfigV1) } -func newModelPackTestArtifact(t *testing.T, modelFile string) *modelPackTestArtifact { +// newModelPackTestArtifactWithMediaType creates a ModelPack test artifact with a specified weight layer media type. +func newModelPackTestArtifactWithMediaType(t *testing.T, modelFile string, weightMediaType oci.MediaType) *modelPackTestArtifact { t.Helper() - layer, err := partial.NewLayer(modelFile, oci.MediaType(modelpack.MediaTypeWeightGGUF)) + layer, err := partial.NewLayer(modelFile, weightMediaType) if err != nil { t.Fatalf("Failed to create ModelPack layer: %v", err) } @@ -172,6 +173,11 @@ func newModelPackTestArtifact(t *testing.T, modelFile string) *modelPackTestArti } } +func newModelPackTestArtifact(t *testing.T, modelFile string) *modelPackTestArtifact { + t.Helper() + return newModelPackTestArtifactWithMediaType(t, modelFile, oci.MediaType(modelpack.MediaTypeWeightGGUF)) +} + // newTestClient creates a new client configured for testing with plain HTTP enabled. func newTestClient(storeRootPath string) (*Client, error) { return NewClient( @@ -349,6 +355,70 @@ func TestClientPullModel(t *testing.T) { } }) + // This test validates compatibility with real CNCF model-spec artifacts + // produced by tools like modctl, which use format-agnostic weight media types + // (e.g., application/vnd.cncf.model.weight.v1.raw) instead of format-specific + // types. The model format is determined from config.format field instead. + t.Run("pull modelpack artifact with raw weight media type", func(t *testing.T) { + tempDir := t.TempDir() + + testClient, err := newTestClient(tempDir) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + mpTag := registryHost + "/modelpack-raw-test/model:v1.0.0" + ref, err := reference.ParseReference(mpTag) + if err != nil { + t.Fatalf("Failed to parse reference: %v", err) + } + + // Use the real model-spec media type that modctl produces + mpModel := newModelPackTestArtifactWithMediaType(t, testGGUFFile, oci.MediaType(modelpack.MediaTypeWeightRaw)) + if err := remote.Write(ref, mpModel, nil, remote.WithPlainHTTP(true)); err != nil { + t.Fatalf("Failed to push ModelPack model: %v", err) + } + + if err := testClient.PullModel(t.Context(), mpTag, nil); err != nil { + t.Fatalf("Failed to pull ModelPack model with raw weight type: %v", err) + } + + pulledModel, err := testClient.GetModel(mpTag) + if err != nil { + t.Fatalf("Failed to get pulled model: %v", err) + } + + ggufPaths, err := pulledModel.GGUFPaths() + if err != nil { + t.Fatalf("Failed to get GGUF paths: %v", err) + } + if len(ggufPaths) != 1 { + t.Fatalf("Unexpected number of GGUF files: %d", len(ggufPaths)) + } + + pulledContent, err := os.ReadFile(ggufPaths[0]) + if err != nil { + t.Fatalf("Failed to read pulled GGUF file: %v", err) + } + + originalContent, err := os.ReadFile(testGGUFFile) + if err != nil { + t.Fatalf("Failed to read source GGUF file: %v", err) + } + + if !bytes.Equal(pulledContent, originalContent) { + t.Errorf("Pulled ModelPack model content doesn't match original") + } + + cfg, err := pulledModel.Config() + if err != nil { + t.Fatalf("Failed to read pulled model config: %v", err) + } + if cfg.GetFormat() != "gguf" { + t.Errorf("Config format = %q, want %q", cfg.GetFormat(), "gguf") + } + }) + t.Run("pull non-existent model", func(t *testing.T) { tempDir := t.TempDir() diff --git a/pkg/distribution/internal/partial/partial.go b/pkg/distribution/internal/partial/partial.go index 8ac1528b2..4f8dd25c0 100644 --- a/pkg/distribution/internal/partial/partial.go +++ b/pkg/distribution/internal/partial/partial.go @@ -87,11 +87,11 @@ type WithLayers interface { } func GGUFPaths(i WithLayers) ([]string, error) { - return layerPathsByMediaType(i, types.MediaTypeGGUF) + return layerPathsByMediaType(i, types.MediaTypeGGUF, getModelFormat(i)) } func MMPROJPath(i WithLayers) (string, error) { - paths, err := layerPathsByMediaType(i, types.MediaTypeMultimodalProjector) + paths, err := layerPathsByMediaType(i, types.MediaTypeMultimodalProjector, "") if err != nil { return "", fmt.Errorf("get mmproj layer paths: %w", err) } @@ -106,7 +106,7 @@ func MMPROJPath(i WithLayers) (string, error) { } func ChatTemplatePath(i WithLayers) (string, error) { - paths, err := layerPathsByMediaType(i, types.MediaTypeChatTemplate) + paths, err := layerPathsByMediaType(i, types.MediaTypeChatTemplate, "") if err != nil { return "", fmt.Errorf("get chat template layer paths: %w", err) } @@ -121,15 +121,15 @@ func ChatTemplatePath(i WithLayers) (string, error) { } func SafetensorsPaths(i WithLayers) ([]string, error) { - return layerPathsByMediaType(i, types.MediaTypeSafetensors) + return layerPathsByMediaType(i, types.MediaTypeSafetensors, getModelFormat(i)) } func DDUFPaths(i WithLayers) ([]string, error) { - return layerPathsByMediaType(i, types.MediaTypeDDUF) + return layerPathsByMediaType(i, types.MediaTypeDDUF, "") } func ConfigArchivePath(i WithLayers) (string, error) { - paths, err := layerPathsByMediaType(i, types.MediaTypeVLLMConfigArchive) + paths, err := layerPathsByMediaType(i, types.MediaTypeVLLMConfigArchive, "") if err != nil { return "", fmt.Errorf("get config archive layer paths: %w", err) } @@ -143,9 +143,22 @@ func ConfigArchivePath(i WithLayers) (string, error) { return paths[0], err } +// getModelFormat reads the model config and returns the format string (e.g., "gguf", "safetensors"). +// This is used to resolve format-agnostic ModelPack weight media types (e.g., .raw, .tar) +// to specific model formats. Returns empty string if format cannot be determined. +func getModelFormat(i WithLayers) string { + cfg, err := Config(i) + if err != nil { + return "" + } + return string(cfg.GetFormat()) +} + // layerPathsByMediaType is a generic helper function that finds a layer by media type and returns its path. // Natively supports both Docker and ModelPack media types without any conversion. -func layerPathsByMediaType(i WithLayers, mediaType oci.MediaType) ([]string, error) { +// The modelFormat parameter is used to resolve format-agnostic ModelPack weight types (e.g., .raw, .tar) +// to the correct model format. Pass empty string when not needed. +func layerPathsByMediaType(i WithLayers, mediaType oci.MediaType, modelFormat string) ([]string, error) { layers, err := i.Layers() if err != nil { return nil, fmt.Errorf("get layers: %w", err) @@ -156,7 +169,7 @@ func layerPathsByMediaType(i WithLayers, mediaType oci.MediaType) ([]string, err if err != nil { continue } - if !matchesMediaType(mt, mediaType) { + if !matchesMediaType(mt, mediaType, modelFormat) { continue } layer, ok := l.(*Layer) @@ -170,25 +183,41 @@ func layerPathsByMediaType(i WithLayers, mediaType oci.MediaType) ([]string, err // matchesMediaType checks if a layer media type matches the target type. // Natively supports both Docker and ModelPack formats without any conversion. -func matchesMediaType(layerMT, targetMT oci.MediaType) bool { +// The modelFormat parameter is used to resolve format-agnostic ModelPack weight types +// (e.g., .raw, .tar) when the format is specified in the model config rather than +// the layer media type. Pass empty string when not needed. +func matchesMediaType(layerMT, targetMT oci.MediaType, modelFormat string) bool { // Exact match if layerMT == targetMT { return true } - // Native ModelPack support: check equivalent ModelPack types + // Native ModelPack support: check format-specific ModelPack types //nolint:exhaustive // Only GGUF and Safetensors need cross-format matching switch targetMT { case types.MediaTypeGGUF: - // ModelPack GGUF layers also match Docker GGUF target - return layerMT == oci.MediaType(modelpack.MediaTypeWeightGGUF) + if layerMT == modelpack.MediaTypeWeightGGUF { + return true + } case types.MediaTypeSafetensors: - // ModelPack safetensors layers also match Docker safetensors target - return layerMT == oci.MediaType(modelpack.MediaTypeWeightSafetensors) - default: - // Other media types have no cross-format equivalents - return false + if layerMT == modelpack.MediaTypeWeightSafetensors { + return true + } } + + // ModelPack model-spec support: format-agnostic weight types (.raw, .tar, etc.) + // The actual model format is determined from the config (config.format field). + if modelFormat != "" && modelpack.IsModelPackWeightMediaType(string(layerMT)) { + //nolint:exhaustive // Only GGUF and Safetensors need cross-format matching + switch targetMT { + case types.MediaTypeGGUF: + return modelFormat == string(types.FormatGGUF) + case types.MediaTypeSafetensors: + return modelFormat == string(types.FormatSafetensors) + } + } + + return false } // WithConfigMediaType provides access to the config media type version. diff --git a/pkg/distribution/internal/partial/partial_test.go b/pkg/distribution/internal/partial/partial_test.go index 20cfee00f..6c9da4086 100644 --- a/pkg/distribution/internal/partial/partial_test.go +++ b/pkg/distribution/internal/partial/partial_test.go @@ -237,3 +237,33 @@ func TestGGUFPaths_ModelPackMediaType(t *testing.T) { t.Errorf("Expected 2 GGUF paths, got %d", len(paths)) } } + +// TestGGUFPaths_ModelPackRawMediaType tests that GGUFPaths can find layers with +// the real CNCF model-spec format-agnostic media type (application/vnd.cncf.model.weight.v1.raw) +// when the model config specifies format as "gguf". +func TestGGUFPaths_ModelPackRawMediaType(t *testing.T) { + // Create a layer with the real model-spec raw weight media type + modelPackRawType := oci.MediaType("application/vnd.cncf.model.weight.v1.raw") + + layer, err := partial.NewLayer(filepath.Join("..", "..", "assets", "dummy.gguf"), modelPackRawType) + if err != nil { + t.Fatalf("Failed to create ModelPack raw layer: %v", err) + } + + // Create a model with mutate and add the layer + mdl := testutil.BuildModelFromPath(t, filepath.Join("..", "..", "assets", "dummy.gguf")) + + mdlWithRawLayer := mutate.AppendLayers(mdl, layer) + + // GGUFPaths should find both: original Docker GGUF + raw ModelPack layer + // because the model config format is "gguf" (set by BuildModelFromPath) + paths, err := partial.GGUFPaths(mdlWithRawLayer) + if err != nil { + t.Fatalf("GGUFPaths() error = %v", err) + } + + // Should find two: original Docker format + raw ModelPack format + if len(paths) != 2 { + t.Errorf("Expected 2 GGUF paths, got %d", len(paths)) + } +} diff --git a/pkg/distribution/modelpack/convert.go b/pkg/distribution/modelpack/convert.go index 3f1acc314..a44d03641 100644 --- a/pkg/distribution/modelpack/convert.go +++ b/pkg/distribution/modelpack/convert.go @@ -61,7 +61,9 @@ func IsModelPackConfig(raw []byte) bool { // MapLayerMediaType maps ModelPack layer media types to Docker format. // Returns the original value if not a ModelPack type. -func MapLayerMediaType(mediaType string) string { +// For format-agnostic types (.raw, .tar), the configFormat parameter is used +// to determine the target Docker media type. +func MapLayerMediaType(mediaType string, configFormat ...string) string { // Only process ModelPack weight layers if !strings.HasPrefix(mediaType, MediaTypePrefix) { return mediaType @@ -73,6 +75,21 @@ func MapLayerMediaType(mediaType string) string { return string(types.MediaTypeGGUF) case strings.Contains(mediaType, "weight") && strings.Contains(mediaType, "safetensors"): return string(types.MediaTypeSafetensors) + case IsModelPackWeightMediaType(mediaType): + // Format-agnostic weight types (.raw, .tar, etc.) from model-spec v0.0.7+. + // Use the config format to determine the target Docker media type. + format := "" + if len(configFormat) > 0 { + format = strings.ToLower(configFormat[0]) + } + switch format { + case "gguf": + return string(types.MediaTypeGGUF) + case "safetensors": + return string(types.MediaTypeSafetensors) + default: + return mediaType + } default: // Keep other layer types (doc, code, etc.) as-is return mediaType diff --git a/pkg/distribution/modelpack/types.go b/pkg/distribution/modelpack/types.go index af4345afc..9f388c51a 100644 --- a/pkg/distribution/modelpack/types.go +++ b/pkg/distribution/modelpack/types.go @@ -22,6 +22,9 @@ const ( // MediaTypePrefix is the prefix for all CNCF model config media types. MediaTypePrefix = "application/vnd.cncf.model." + // MediaTypeWeightPrefix is the prefix for all CNCF model weight media types. + MediaTypeWeightPrefix = "application/vnd.cncf.model.weight." + // MediaTypeModelConfigV1 is the CNCF model config v1 media type. MediaTypeModelConfigV1 = "application/vnd.cncf.model.config.v1+json" @@ -30,8 +33,28 @@ const ( // MediaTypeWeightSafetensors is the CNCF ModelPack media type for safetensors weight layers. MediaTypeWeightSafetensors = "application/vnd.cncf.model.weight.v1.safetensors" + + // MediaTypeWeightRaw is the CNCF model-spec media type for unarchived, uncompressed model weights. + // This is the actual type used by modctl and the official model-spec (v0.0.7+). + MediaTypeWeightRaw = "application/vnd.cncf.model.weight.v1.raw" + + // MediaTypeWeightTar is the CNCF model-spec media type for tar-archived model weights. + MediaTypeWeightTar = "application/vnd.cncf.model.weight.v1.tar" + + // MediaTypeWeightTarGzip is the CNCF model-spec media type for gzipped tar-archived model weights. + MediaTypeWeightTarGzip = "application/vnd.cncf.model.weight.v1.tar+gzip" + + // MediaTypeWeightTarZstd is the CNCF model-spec media type for zstd-compressed tar-archived model weights. + MediaTypeWeightTarZstd = "application/vnd.cncf.model.weight.v1.tar+zstd" ) +// IsModelPackWeightMediaType checks if the given media type is a CNCF ModelPack weight layer type. +// This includes both format-specific types (e.g., .gguf, .safetensors) and +// format-agnostic types from the official model-spec (e.g., .raw, .tar). +func IsModelPackWeightMediaType(mediaType string) bool { + return strings.HasPrefix(mediaType, MediaTypeWeightPrefix) +} + // Model represents the CNCF ModelPack config structure. // It provides the `application/vnd.cncf.model.config.v1+json` mediatype when marshalled to JSON. type Model struct {