Skip to content

Commit bb92e7a

Browse files
committed
feat(distribution): add CNCF ModelPack format compatibility
Add support for pulling models in CNCF ModelPack format by converting them to Docker model-spec format on-the-fly during pull operations. - Add modelpack package with type definitions and conversion logic - Convert config fields (paramSize -> parameters, createdAt -> created) - Convert layer media types (cncf.model.weight -> docker.ai.gguf) - Preserve extended metadata in Config.ModelPack extension field - Add comprehensive test coverage Closes: docker/model-spec#3 Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent f12b17b commit bb92e7a

File tree

8 files changed

+1434
-7
lines changed

8 files changed

+1434
-7
lines changed

pkg/distribution/distribution/client.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/docker/model-runner/pkg/distribution/internal/progress"
1616
"github.com/docker/model-runner/pkg/distribution/internal/store"
17+
"github.com/docker/model-runner/pkg/distribution/modelpack"
1718
"github.com/docker/model-runner/pkg/distribution/registry"
1819
"github.com/docker/model-runner/pkg/distribution/tarball"
1920
"github.com/docker/model-runner/pkg/distribution/types"
@@ -213,11 +214,22 @@ func (c *Client) PullModel(ctx context.Context, reference string, progressWriter
213214
}
214215
}
215216

216-
// Check for supported type
217-
if err := checkCompat(remoteModel, c.log, reference, progressWriter); err != nil {
217+
// Check for supported type and convert ModelPack format if needed
218+
remoteModel, err = checkAndConvertCompat(remoteModel, c.log, reference, progressWriter)
219+
if err != nil {
218220
return err
219221
}
220222

223+
// Update digest after potential conversion (ModelPack conversion changes the manifest)
224+
convertedDigest, err := remoteModel.Digest()
225+
if err != nil {
226+
return fmt.Errorf("getting converted model digest: %w", err)
227+
}
228+
if convertedDigest != remoteDigest {
229+
c.log.Infof("Model converted from ModelPack format, new digest: %s", convertedDigest.String())
230+
}
231+
remoteDigest = convertedDigest
232+
221233
// Check if model exists in local store
222234
localModel, err := c.store.Read(remoteDigest.String())
223235
if err == nil {
@@ -474,19 +486,48 @@ func GetSupportedFormats() []types.Format {
474486
return []types.Format{types.FormatGGUF}
475487
}
476488

477-
func checkCompat(image types.ModelArtifact, log *logrus.Entry, reference string, progressWriter io.Writer) error {
489+
// checkAndConvertCompat validates model compatibility and converts ModelPack format if needed.
490+
// If the model is in CNCF ModelPack format, it will be converted to Docker format transparently.
491+
// Returns the (possibly converted) model artifact and any error.
492+
func checkAndConvertCompat(image types.ModelArtifact, log *logrus.Entry, reference string, progressWriter io.Writer) (types.ModelArtifact, error) {
478493
manifest, err := image.Manifest()
479494
if err != nil {
480-
return err
495+
return nil, err
496+
}
497+
498+
mediaTypeStr := string(manifest.Config.MediaType)
499+
500+
// Handle CNCF ModelPack format by converting to Docker format
501+
if modelpack.IsModelPackMediaType(mediaTypeStr) {
502+
log.Infof("Detected ModelPack format for %s, converting to Docker format",
503+
utils.SanitizeForLog(reference))
504+
505+
converted, err := modelpack.NewConvertedArtifact(image)
506+
if err != nil {
507+
return nil, fmt.Errorf("converting ModelPack format: %w", err)
508+
}
509+
510+
if err := progress.WriteInfo(progressWriter, "Converting ModelPack to Docker format..."); err != nil {
511+
log.Warnf("Failed to write info message: %v", err)
512+
}
513+
514+
// Continue validation with converted model
515+
image = converted
516+
manifest, err = image.Manifest()
517+
if err != nil {
518+
return nil, fmt.Errorf("get converted manifest: %w", err)
519+
}
481520
}
521+
522+
// Validate Docker format
482523
if manifest.Config.MediaType != types.MediaTypeModelConfigV01 {
483-
return fmt.Errorf("config type %q is unsupported: %w", manifest.Config.MediaType, ErrUnsupportedMediaType)
524+
return nil, fmt.Errorf("config type %q is unsupported: %w", manifest.Config.MediaType, ErrUnsupportedMediaType)
484525
}
485526

486527
// Check if the model format is supported
487528
config, err := image.Config()
488529
if err != nil {
489-
return fmt.Errorf("reading model config: %w", err)
530+
return nil, fmt.Errorf("reading model config: %w", err)
490531
}
491532

492533
if config.Format == "" {
@@ -501,5 +542,5 @@ func checkCompat(image types.ModelArtifact, log *logrus.Entry, reference string,
501542
// Don't return an error - allow the pull to continue
502543
}
503544

504-
return nil
545+
return image, nil
505546
}

pkg/distribution/internal/progress/reporter.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ func WriteProgress(w io.Writer, msg string, imageSize, layerSize, current uint64
139139
})
140140
}
141141

142+
// WriteInfo writes an info message
143+
func WriteInfo(w io.Writer, message string) error {
144+
return write(w, Message{
145+
Type: "info",
146+
Message: message,
147+
})
148+
}
149+
142150
// WriteSuccess writes a success message
143151
func WriteSuccess(w io.Writer, message string) error {
144152
return write(w, Message{
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package modelpack
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"maps"
8+
"strings"
9+
10+
v1 "github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1"
11+
"github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1/partial"
12+
ggcr "github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1/types"
13+
14+
mdpartial "github.com/docker/model-runner/pkg/distribution/internal/partial"
15+
"github.com/docker/model-runner/pkg/distribution/types"
16+
)
17+
18+
// Ensure convertedArtifact implements types.ModelArtifact
19+
var _ types.ModelArtifact = &convertedArtifact{}
20+
21+
// convertedArtifact wraps a ModelPack format model artifact and presents it
22+
// as a Docker format model artifact. It converts the config on-the-fly while
23+
// delegating layer operations to the underlying source.
24+
type convertedArtifact struct {
25+
source types.ModelArtifact
26+
convertedConfig *types.ConfigFile
27+
rawConvertedJSON []byte
28+
configDigest v1.Hash
29+
}
30+
31+
// NewConvertedArtifact creates a new artifact wrapper that converts a ModelPack
32+
// format model to Docker format. The conversion happens immediately and the
33+
// converted config is cached for subsequent accesses.
34+
func NewConvertedArtifact(source types.ModelArtifact) (*convertedArtifact, error) {
35+
// Get the original ModelPack config
36+
rawConfig, err := source.RawConfigFile()
37+
if err != nil {
38+
return nil, fmt.Errorf("get source config: %w", err)
39+
}
40+
41+
// Convert to Docker format
42+
converted, err := ConvertToDockerConfig(rawConfig)
43+
if err != nil {
44+
return nil, fmt.Errorf("convert config: %w", err)
45+
}
46+
47+
// Serialize the converted config
48+
rawJSON, err := json.Marshal(converted)
49+
if err != nil {
50+
return nil, fmt.Errorf("marshal converted config: %w", err)
51+
}
52+
53+
// Compute the digest of the converted config
54+
configDigest, _, err := v1.SHA256(bytes.NewReader(rawJSON))
55+
if err != nil {
56+
return nil, fmt.Errorf("compute config digest: %w", err)
57+
}
58+
59+
return &convertedArtifact{
60+
source: source,
61+
convertedConfig: converted,
62+
rawConvertedJSON: rawJSON,
63+
configDigest: configDigest,
64+
}, nil
65+
}
66+
67+
// ID returns the model ID (manifest digest).
68+
// This will differ from the source because the config has changed.
69+
func (a *convertedArtifact) ID() (string, error) {
70+
return mdpartial.ID(a)
71+
}
72+
73+
// Config returns the converted Docker format config.
74+
func (a *convertedArtifact) Config() (types.Config, error) {
75+
return a.convertedConfig.Config, nil
76+
}
77+
78+
// Descriptor returns the model descriptor (provenance info).
79+
func (a *convertedArtifact) Descriptor() (types.Descriptor, error) {
80+
return a.convertedConfig.Descriptor, nil
81+
}
82+
83+
// Layers returns the model layers from the source.
84+
// Layers are unchanged during conversion.
85+
func (a *convertedArtifact) Layers() ([]v1.Layer, error) {
86+
return a.source.Layers()
87+
}
88+
89+
// MediaType returns the manifest media type.
90+
func (a *convertedArtifact) MediaType() (ggcr.MediaType, error) {
91+
return a.source.MediaType()
92+
}
93+
94+
// Size returns the size of the manifest.
95+
func (a *convertedArtifact) Size() (int64, error) {
96+
return partial.Size(a)
97+
}
98+
99+
// ConfigName returns the hash of the converted config file.
100+
func (a *convertedArtifact) ConfigName() (v1.Hash, error) {
101+
return a.configDigest, nil
102+
}
103+
104+
// ConfigFile returns nil as this is a model artifact, not a container image.
105+
// Model artifacts use RawConfigFile() to access configuration.
106+
func (a *convertedArtifact) ConfigFile() (*v1.ConfigFile, error) {
107+
return nil, fmt.Errorf("ConfigFile is not supported for model artifacts; use RawConfigFile instead")
108+
}
109+
110+
// RawConfigFile returns the serialized bytes of the converted Docker format config.
111+
// This is the key method that makes the conversion work - all other code that
112+
// reads the config will get the Docker format version.
113+
func (a *convertedArtifact) RawConfigFile() ([]byte, error) {
114+
return a.rawConvertedJSON, nil
115+
}
116+
117+
// Digest returns the sha256 of the manifest.
118+
// This will differ from the source because the config digest has changed.
119+
func (a *convertedArtifact) Digest() (v1.Hash, error) {
120+
return partial.Digest(a)
121+
}
122+
123+
// Manifest returns the manifest with Docker config media type.
124+
func (a *convertedArtifact) Manifest() (*v1.Manifest, error) {
125+
// Get the source manifest as a base
126+
srcManifest, err := a.source.Manifest()
127+
if err != nil {
128+
return nil, fmt.Errorf("get source manifest: %w", err)
129+
}
130+
131+
// Deep copy layers and convert media types from ModelPack to Docker format
132+
layers := make([]v1.Descriptor, len(srcManifest.Layers))
133+
for i, layer := range srcManifest.Layers {
134+
layers[i] = v1.Descriptor{
135+
MediaType: convertLayerMediaType(layer.MediaType, a.convertedConfig.Config.Format),
136+
Size: layer.Size,
137+
Digest: layer.Digest,
138+
URLs: layer.URLs,
139+
Annotations: maps.Clone(layer.Annotations),
140+
Data: layer.Data,
141+
Platform: layer.Platform,
142+
}
143+
}
144+
145+
// Create a deep copy to avoid sharing references with source
146+
manifest := &v1.Manifest{
147+
SchemaVersion: srcManifest.SchemaVersion,
148+
MediaType: srcManifest.MediaType,
149+
Config: v1.Descriptor{
150+
MediaType: types.MediaTypeModelConfigV01,
151+
Size: int64(len(a.rawConvertedJSON)),
152+
Digest: a.configDigest,
153+
},
154+
Layers: layers,
155+
Annotations: maps.Clone(srcManifest.Annotations),
156+
Subject: srcManifest.Subject, // Preserve referrer relationship if present
157+
}
158+
159+
return manifest, nil
160+
}
161+
162+
// convertLayerMediaType converts ModelPack layer media types to Docker format.
163+
// This is essential because docker/model-runner uses media types to identify layer types.
164+
func convertLayerMediaType(srcMediaType ggcr.MediaType, format types.Format) ggcr.MediaType {
165+
mediaTypeStr := string(srcMediaType)
166+
167+
// Only convert if it's a ModelPack media type
168+
if !strings.HasPrefix(mediaTypeStr, MediaTypePrefix) {
169+
return srcMediaType
170+
}
171+
172+
// Convert based on the model format
173+
switch format {
174+
case types.FormatGGUF:
175+
// ModelPack weight layers become Docker GGUF layers
176+
if strings.Contains(mediaTypeStr, ".weight.") {
177+
return types.MediaTypeGGUF
178+
}
179+
case types.FormatSafetensors:
180+
// ModelPack weight layers become Docker safetensors layers
181+
if strings.Contains(mediaTypeStr, ".weight.") {
182+
return types.MediaTypeSafetensors
183+
}
184+
}
185+
186+
// For other media types (doc, code, dataset, etc.), keep as-is
187+
// These aren't used by docker/model-runner's core inference path
188+
return srcMediaType
189+
}
190+
191+
// RawManifest returns the serialized bytes of the converted manifest.
192+
func (a *convertedArtifact) RawManifest() ([]byte, error) {
193+
manifest, err := a.Manifest()
194+
if err != nil {
195+
return nil, err
196+
}
197+
return json.Marshal(manifest)
198+
}
199+
200+
// LayerByDigest returns a layer by its digest.
201+
func (a *convertedArtifact) LayerByDigest(hash v1.Hash) (v1.Layer, error) {
202+
return a.source.LayerByDigest(hash)
203+
}
204+
205+
// LayerByDiffID returns a layer by its diff ID.
206+
func (a *convertedArtifact) LayerByDiffID(hash v1.Hash) (v1.Layer, error) {
207+
return a.source.LayerByDiffID(hash)
208+
}

0 commit comments

Comments
 (0)