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
40 changes: 1 addition & 39 deletions pkg/lib/filesystem/unpack/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import (
"github.com/kitops-ml/kitops/pkg/lib/repo/util"
"github.com/kitops-ml/kitops/pkg/output"

modelspecv1 "github.com/modelpack/model-spec/specs-go/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2/content"
)
Expand Down Expand Up @@ -96,7 +95,7 @@ func unpackRecursive(ctx context.Context, opts *UnpackOptions, visitedRefs []str
output.Logf(output.LogLevelWarn, "Could not get Kitfile: %s", err)
output.Logf(output.LogLevelWarn, "Functionality may be impacted")
// TODO: we can probably _also_ handle getting the model-spec config and using it here
genconfig, err := generateKitfileForModelPack(manifest)
genconfig, err := util.GenerateKitfileForModelPack(manifest)
if err != nil {
return fmt.Errorf("could not process manifest: %w", err)
}
Expand Down Expand Up @@ -481,40 +480,3 @@ func getIndex(list []string, s string) int {
}
return -1
}

// generateKitfileForModelPack generates a "Kitfile" for a manifest that otherwise does not contain one.
// This is a minimal Kitfile suitable only for unpacking, containing a path for every layer. If a layer
// does not use the 'org.cncf.model.filepath' annotation, an error is returned.
func generateKitfileForModelPack(manifest *ocispec.Manifest) (*artifact.KitFile, error) {
if format, err := mediatype.ModelFormatForManifest(manifest); err != nil || format != mediatype.ModelPackFormat {
return nil, fmt.Errorf("not a modelpack artifact")
}
kf := &artifact.KitFile{
Model: &artifact.Model{},
}
for _, desc := range manifest.Layers {
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
mt, err := mediatype.ParseMediaType(desc.MediaType)
if err != nil {
return nil, err
}
switch mt.Base() {
case mediatype.ModelBaseType:
kf.Model.Path = filepath
case mediatype.ModelPartBaseType:
kf.Model.Parts = append(kf.Model.Parts, artifact.ModelPart{Path: filepath})
case mediatype.CodeBaseType:
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
case mediatype.DatasetBaseType:
kf.DataSets = append(kf.DataSets, artifact.DataSet{Path: filepath})
case mediatype.DocsBaseType:
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
default:
return nil, fmt.Errorf("unknown layer type: %s", mt)
}
}
return kf, nil
}
63 changes: 63 additions & 0 deletions pkg/lib/repo/util/modelpack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2026 The KitOps Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package util

import (
"fmt"

"github.com/kitops-ml/kitops/pkg/artifact"
"github.com/kitops-ml/kitops/pkg/lib/constants/mediatype"
modelspecv1 "github.com/modelpack/model-spec/specs-go/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// GenerateKitfileForModelPack generates a minimal Kitfile for a ModelPack manifest.
// This fallback is suitable for unpack and inspect workflows when no Kitfile is
// available in annotations.
func GenerateKitfileForModelPack(manifest *ocispec.Manifest) (*artifact.KitFile, error) {
if format, err := mediatype.ModelFormatForManifest(manifest); err != nil || format != mediatype.ModelPackFormat {
return nil, fmt.Errorf("not a modelpack artifact")
}
kf := &artifact.KitFile{
Model: &artifact.Model{},
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The generated Kitfile leaves ManifestVersion empty. Since ManifestVersion is not omitempty, this will serialize as an empty string when unpack writes a Kitfile to disk (and may trigger validation warnings elsewhere). Set a default manifestVersion (e.g. "1.0.0") on the synthesized Kitfile to keep it consistent with normal Kitfiles.

Suggested change
Model: &artifact.Model{},
ManifestVersion: "1.0.0",
Model: &artifact.Model{},

Copilot uses AI. Check for mistakes.
}
for _, desc := range manifest.Layers {
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
mt, err := mediatype.ParseMediaType(desc.MediaType)
if err != nil {
return nil, err
}
switch mt.Base() {
case mediatype.ModelBaseType:
kf.Model.Path = filepath
case mediatype.ModelPartBaseType:
kf.Model.Parts = append(kf.Model.Parts, artifact.ModelPart{Path: filepath})
case mediatype.CodeBaseType:
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

GenerateKitfileForModelPack treats every code layer as regular code. Unpack logic differentiates prompt layers via the ml.kitops.modelkit.layer-subtype annotation and indexes into config.Prompts for those; with the current generator this can lead to an index-out-of-range panic (Prompts stays empty) when a ModelPack includes prompt layers. Handle the prompt subtype annotation here by populating KitFile.Prompts (and leaving KitFile.Code for non-prompt code layers).

Suggested change
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
subtype := ""
if desc.Annotations != nil {
subtype = desc.Annotations["ml.kitops.modelkit.layer-subtype"]
}
if subtype == "prompt" {
kf.Prompts = append(kf.Prompts, artifact.Prompt{Path: filepath})
} else {
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
}

Copilot uses AI. Check for mistakes.
case mediatype.DatasetBaseType:
kf.DataSets = append(kf.DataSets, artifact.DataSet{Path: filepath})
case mediatype.DocsBaseType:
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

GenerateKitfileForModelPack errors on any layer whose parsed base type isn't model/modelpart/code/dataset/docs. ModelPacks can include config-type layers (and unpack already explicitly expects to skip ConfigBaseType layers), so returning an error here can prevent unpack/inspect from working even though the extra layer could be safely ignored. Consider explicitly skipping ConfigBaseType layers (and potentially other non-content layers) instead of treating them as fatal.

Suggested change
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
case mediatype.ConfigBaseType:
// Config layers do not contribute to the Kitfile contents; safely ignore.

Copilot uses AI. Check for mistakes.
default:
return nil, fmt.Errorf("unknown layer type: %s", mt)
Comment on lines +39 to +59
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

GenerateKitfileForModelPack requires org.cncf.model.filepath on every layer before even checking the layer media type. If the manifest contains config layers or any layer types you intend to skip, this will return an error unnecessarily. Reorder the logic to parse/recognize the media type first (and skip config/unknown layers) before enforcing the filepath annotation requirement.

Suggested change
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
mt, err := mediatype.ParseMediaType(desc.MediaType)
if err != nil {
return nil, err
}
switch mt.Base() {
case mediatype.ModelBaseType:
kf.Model.Path = filepath
case mediatype.ModelPartBaseType:
kf.Model.Parts = append(kf.Model.Parts, artifact.ModelPart{Path: filepath})
case mediatype.CodeBaseType:
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
case mediatype.DatasetBaseType:
kf.DataSets = append(kf.DataSets, artifact.DataSet{Path: filepath})
case mediatype.DocsBaseType:
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
default:
return nil, fmt.Errorf("unknown layer type: %s", mt)
mt, err := mediatype.ParseMediaType(desc.MediaType)
if err != nil {
return nil, err
}
switch mt.Base() {
case mediatype.ModelBaseType:
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
kf.Model.Path = filepath
case mediatype.ModelPartBaseType:
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
kf.Model.Parts = append(kf.Model.Parts, artifact.ModelPart{Path: filepath})
case mediatype.CodeBaseType:
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
kf.Code = append(kf.Code, artifact.Code{Path: filepath})
case mediatype.DatasetBaseType:
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
kf.DataSets = append(kf.DataSets, artifact.DataSet{Path: filepath})
case mediatype.DocsBaseType:
if desc.Annotations == nil || desc.Annotations[modelspecv1.AnnotationFilepath] == "" {
return nil, fmt.Errorf("unknown file path for layer: no %s annotation", modelspecv1.AnnotationFilepath)
}
filepath := desc.Annotations[modelspecv1.AnnotationFilepath]
kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath})
default:
// Skip layers that are not recognized as modelpack content (e.g., config or unknown types).
continue

Copilot uses AI. Check for mistakes.
}
}
return kf, nil
}
90 changes: 90 additions & 0 deletions pkg/lib/repo/util/modelpack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2026 The KitOps Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package util

import (
"testing"

"github.com/kitops-ml/kitops/pkg/lib/constants/mediatype"
modelspecv1 "github.com/modelpack/model-spec/specs-go/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/require"
)

func TestGenerateKitfileForModelPack(t *testing.T) {
manifest := &ocispec.Manifest{
ArtifactType: mediatype.ArtifactTypeModelManifest,
Config: ocispec.Descriptor{
MediaType: mediatype.ModelPackConfigMediaType.String(),
},
Layers: []ocispec.Descriptor{
{
MediaType: mediatype.New(mediatype.ModelPackFormat, mediatype.ModelBaseType, mediatype.TarFormat, mediatype.GzipCompression).String(),
Annotations: map[string]string{
modelspecv1.AnnotationFilepath: "models/main.gguf",
},
},
{
MediaType: mediatype.New(mediatype.ModelPackFormat, mediatype.CodeBaseType, mediatype.TarFormat, mediatype.GzipCompression).String(),
Annotations: map[string]string{
modelspecv1.AnnotationFilepath: "src/app.py",
},
},
{
MediaType: mediatype.New(mediatype.ModelPackFormat, mediatype.DatasetBaseType, mediatype.TarFormat, mediatype.GzipCompression).String(),
Annotations: map[string]string{
modelspecv1.AnnotationFilepath: "data/train.csv",
},
},
{
MediaType: mediatype.New(mediatype.ModelPackFormat, mediatype.DocsBaseType, mediatype.TarFormat, mediatype.GzipCompression).String(),
Annotations: map[string]string{
modelspecv1.AnnotationFilepath: "docs/readme.md",
},
},
},
}

kf, err := GenerateKitfileForModelPack(manifest)
require.NoError(t, err)
require.NotNil(t, kf.Model)
require.Equal(t, "models/main.gguf", kf.Model.Path)
require.Len(t, kf.Code, 1)
require.Equal(t, "src/app.py", kf.Code[0].Path)
require.Len(t, kf.DataSets, 1)
require.Equal(t, "data/train.csv", kf.DataSets[0].Path)
require.Len(t, kf.Docs, 1)
require.Equal(t, "docs/readme.md", kf.Docs[0].Path)
}
Comment on lines +28 to +72
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Current tests cover basic layer-to-Kitfile mapping and missing filepath, but they don’t cover prompt subtypes (code layer with ml.kitops.modelkit.layer-subtype=prompt) or manifests containing ConfigBaseType/unknown layers that should be skipped. Adding tests for these cases will help prevent regressions and catch the unpack indexing/panic scenario.

Copilot uses AI. Check for mistakes.

func TestGenerateKitfileForModelPackMissingPathAnnotation(t *testing.T) {
manifest := &ocispec.Manifest{
ArtifactType: mediatype.ArtifactTypeModelManifest,
Config: ocispec.Descriptor{
MediaType: mediatype.ModelPackConfigMediaType.String(),
},
Layers: []ocispec.Descriptor{
{
MediaType: mediatype.New(mediatype.ModelPackFormat, mediatype.ModelBaseType, mediatype.TarFormat, mediatype.GzipCompression).String(),
},
},
}

_, err := GenerateKitfileForModelPack(manifest)
require.Error(t, err)
require.Contains(t, err.Error(), modelspecv1.AnnotationFilepath)
}
17 changes: 8 additions & 9 deletions pkg/lib/repo/util/reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,15 @@ func GetKitfileForManifest(ctx context.Context, store oras.ReadOnlyTarget, manif
case mediatype.KitFormat:
return GetConfig(ctx, store, manifest.Config)
case mediatype.ModelPackFormat:
// TODO: can we (try to) generate a Kitfile from a ModelPack manifest?
if manifest.Annotations == nil || manifest.Annotations[constants.KitfileJsonAnnotation] == "" {
return nil, ErrNoKitfile
if manifest.Annotations != nil && manifest.Annotations[constants.KitfileJsonAnnotation] != "" {
kfstring := manifest.Annotations[constants.KitfileJsonAnnotation]
kitfile := &artifact.KitFile{}
if err := json.Unmarshal([]byte(kfstring), kitfile); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The error message returned when unmarshalling the Kitfile JSON annotation says "failed to parse config", but this path is parsing the Kitfile stored in a manifest annotation (not the OCI config blob). Updating the message to reference the Kitfile annotation will make failures easier to diagnose.

Suggested change
return nil, fmt.Errorf("failed to parse config: %w", err)
return nil, fmt.Errorf("failed to parse Kitfile JSON annotation: %w", err)

Copilot uses AI. Check for mistakes.
}
return kitfile, nil
}
kfstring := manifest.Annotations[constants.KitfileJsonAnnotation]
kitfile := &artifact.KitFile{}
if err := json.Unmarshal([]byte(kfstring), kitfile); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return kitfile, nil
return GenerateKitfileForModelPack(manifest)
default:
// Won't happen but necessary for completeness
return nil, fmt.Errorf("unknown artifact type")
Expand Down
Loading