Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions pkg/distribution/distribution/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"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/registry"
"github.com/docker/model-runner/pkg/distribution/tarball"
"github.com/docker/model-runner/pkg/distribution/types"
Expand Down Expand Up @@ -213,11 +214,22 @@ func (c *Client) PullModel(ctx context.Context, reference string, progressWriter
}
}

// Check for supported type
if err := checkCompat(remoteModel, c.log, reference, progressWriter); err != nil {
// Check for supported type and convert ModelPack format if needed
remoteModel, err = checkAndConvertCompat(remoteModel, c.log, reference, progressWriter)
if err != nil {
return err
}

// Update digest after potential conversion (ModelPack conversion changes the manifest)
convertedDigest, err := remoteModel.Digest()
if err != nil {
return fmt.Errorf("getting converted model digest: %w", err)
}
if convertedDigest != remoteDigest {
c.log.Infof("Model converted from ModelPack format, new digest: %s", convertedDigest.String())
}
Comment on lines +228 to +230
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The digest comparison check occurs after conversion, but the log message is only written when digests differ. Since ModelPack conversion always produces a different digest (due to config changes), consider removing this conditional and always logging the conversion, or restructure to log before conversion is attempted.

Suggested change
if convertedDigest != remoteDigest {
c.log.Infof("Model converted from ModelPack format, new digest: %s", convertedDigest.String())
}
c.log.Infof("Model converted from ModelPack format, new digest: %s", convertedDigest.String())

Copilot uses AI. Check for mistakes.
remoteDigest = convertedDigest

// Check if model exists in local store
localModel, err := c.store.Read(remoteDigest.String())
if err == nil {
Expand Down Expand Up @@ -474,19 +486,38 @@ func GetSupportedFormats() []types.Format {
return []types.Format{types.FormatGGUF}
}

func checkCompat(image types.ModelArtifact, log *logrus.Entry, reference string, progressWriter io.Writer) error {
// checkAndConvertCompat validates model compatibility.
// Both Docker format and CNCF ModelPack format are supported.
// ModelPack format is stored as-is and converted at read time.
func checkAndConvertCompat(image types.ModelArtifact, log *logrus.Entry, reference string, progressWriter io.Writer) (types.ModelArtifact, error) {
manifest, err := image.Manifest()
if err != nil {
return err
return nil, err
}

mediaTypeStr := string(manifest.Config.MediaType)

// 檢查是不是支援的格式(Docker 或 ModelPack)
isDocker := mediaTypeStr == string(types.MediaTypeModelConfigV01)
isModelPack := modelpack.IsModelPackMediaType(mediaTypeStr)

if !isDocker && !isModelPack {
return nil, fmt.Errorf("config type %q is unsupported: %w", mediaTypeStr, ErrUnsupportedMediaType)
}
if manifest.Config.MediaType != types.MediaTypeModelConfigV01 {
return fmt.Errorf("config type %q is unsupported: %w", manifest.Config.MediaType, ErrUnsupportedMediaType)

// ModelPack 格式會原封不動儲存,讀取時再轉換
if isModelPack {
log.Infof("Detected ModelPack format for %s (stored as-is)",
utils.SanitizeForLog(reference))
if err := progress.WriteInfo(progressWriter, "ModelPack format detected"); err != nil {
log.Warnf("Failed to write info message: %v", err)
}
}

// Check if the model format is supported
config, err := image.Config()
if err != nil {
return fmt.Errorf("reading model config: %w", err)
return nil, fmt.Errorf("reading model config: %w", err)
}

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

return nil
return image, nil
}
16 changes: 14 additions & 2 deletions pkg/distribution/internal/partial/partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1/partial"
ggcr "github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1/types"

"github.com/docker/model-runner/pkg/distribution/modelpack"
"github.com/docker/model-runner/pkg/distribution/types"
)

Expand All @@ -21,9 +22,15 @@ func ConfigFile(i WithRawConfigFile) (*types.ConfigFile, error) {
if err != nil {
return nil, fmt.Errorf("get raw config file: %w", err)
}

// 自動偵測 ModelPack 格式,讀取時轉換成 Docker 格式
if modelpack.IsModelPackConfig(raw) {
return modelpack.ConvertToDockerConfig(raw)
}

var cf types.ConfigFile
if err := json.Unmarshal(raw, &cf); err != nil {
return nil, fmt.Errorf("unmarshal : %w", err)
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cf, nil
}
Expand Down Expand Up @@ -127,7 +134,12 @@ func layerPathsByMediaType(i WithLayers, mediaType ggcr.MediaType) ([]string, er
var paths []string
for _, l := range layers {
mt, err := l.MediaType()
if err != nil || mt != mediaType {
if err != nil {
continue
}
// 把 ModelPack 的媒體類型轉成 Docker 格式再比對
mappedMT := ggcr.MediaType(modelpack.MapLayerMediaType(string(mt)))
if mappedMT != mediaType {
continue
}
layer, ok := l.(*Layer)
Expand Down
98 changes: 98 additions & 0 deletions pkg/distribution/internal/partial/partial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,76 @@ import (
"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/types"
ggcr "github.com/docker/model-runner/pkg/go-containerregistry/pkg/v1/types"
)

// mockConfig 用來測試 ConfigFile 函數
type mockConfig struct {
raw []byte
err error
}

func (m *mockConfig) RawConfigFile() ([]byte, error) {
return m.raw, m.err
}

// TestConfigFile_AutoDetection 測試 ConfigFile 能自動偵測並轉換 ModelPack 格式
func TestConfigFile_AutoDetection(t *testing.T) {
t.Run("Docker format passes through", func(t *testing.T) {
// Docker 格式的 config
dockerJSON := `{
"config": {"format": "gguf", "parameters": "8B"},
"descriptor": {},
"rootfs": {"type": "layers", "diff_ids": []}
}`

mock := &mockConfig{raw: []byte(dockerJSON)}
cf, err := partial.ConfigFile(mock)
if err != nil {
t.Fatalf("ConfigFile() error = %v", err)
}

if cf.Config.Format != types.FormatGGUF {
t.Errorf("Format = %v, want %v", cf.Config.Format, types.FormatGGUF)
}
if cf.Config.Parameters != "8B" {
t.Errorf("Parameters = %q, want %q", cf.Config.Parameters, "8B")
}
})

t.Run("ModelPack format auto-converts", func(t *testing.T) {
// ModelPack 格式的 config(用 paramSize 不是 parameters)
modelPackJSON := `{
"descriptor": {"createdAt": "2025-01-15T10:30:00Z"},
"config": {"format": "gguf", "paramSize": "8B"},
"modelfs": {"type": "layers", "diffIds": []}
}`

mock := &mockConfig{raw: []byte(modelPackJSON)}
cf, err := partial.ConfigFile(mock)
if err != nil {
t.Fatalf("ConfigFile() error = %v", err)
}

// 轉換後應該要有 Docker 格式的欄位
if cf.Config.Format != types.FormatGGUF {
t.Errorf("Format = %v, want %v", cf.Config.Format, types.FormatGGUF)
}
// paramSize 應該被轉成 parameters
if cf.Config.Parameters != "8B" {
t.Errorf("Parameters = %q, want %q", cf.Config.Parameters, "8B")
}
})

t.Run("invalid JSON returns error", func(t *testing.T) {
mock := &mockConfig{raw: []byte("not valid json")}
_, err := partial.ConfigFile(mock)
if err == nil {
t.Error("expected error for invalid JSON")
}
})
}

func TestMMPROJPath(t *testing.T) {
// Create a model from GGUF file
mdl, err := gguf.NewModel(filepath.Join("..", "..", "assets", "dummy.gguf"))
Expand Down Expand Up @@ -122,3 +190,33 @@ func TestLayerPathByMediaType(t *testing.T) {
}

}

// TestGGUFPaths_ModelPackMediaType 測試 GGUFPaths 能找到 ModelPack 格式的層
func TestGGUFPaths_ModelPackMediaType(t *testing.T) {
// 用 ModelPack 的 GGUF 媒體類型建立層
modelPackGGUFType := ggcr.MediaType("application/vnd.cncf.model.weight.v1.gguf")

layer, err := partial.NewLayer(filepath.Join("..", "..", "assets", "dummy.gguf"), modelPackGGUFType)
if err != nil {
t.Fatalf("建立 ModelPack 層失敗: %v", err)
}

// 用 mutate 建立含有這個層的 model
mdl, err := gguf.NewModel(filepath.Join("..", "..", "assets", "dummy.gguf"))
if err != nil {
t.Fatalf("建立 GGUF model 失敗: %v", err)
}

mdlWithModelPackLayer := mutate.AppendLayers(mdl, layer)

// GGUFPaths 應該要能找到 ModelPack 格式的 GGUF 層
paths, err := partial.GGUFPaths(mdlWithModelPackLayer)
if err != nil {
t.Fatalf("GGUFPaths() error = %v", err)
}

// 應該找到兩個:原本的 Docker 格式 + 新加的 ModelPack 格式
if len(paths) != 2 {
t.Errorf("預期找到 2 個 GGUF 路徑, 實際找到 %d 個", len(paths))
}
}
8 changes: 8 additions & 0 deletions pkg/distribution/internal/progress/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ func WriteProgress(w io.Writer, msg string, imageSize, layerSize, current uint64
})
}

// WriteInfo writes an info message
func WriteInfo(w io.Writer, message string) error {
return write(w, Message{
Type: "info",
Message: message,
})
}

// WriteSuccess writes a success message
func WriteSuccess(w io.Writer, message string) error {
return write(w, Message{
Expand Down
Loading