fix: generate Kitfile for ModelPack if missing from manifest#1142
fix: generate Kitfile for ModelPack if missing from manifest#1142AdeshDeshmukh wants to merge 1 commit intokitops-ml:mainfrom
Conversation
This commit introduces a fallback mechanism to generate a Kitfile in-memory when a ModelPack manifest does not contain one in its annotations. This allows tools to inspect and unpack ModelPack artifacts that are missing an explicit Kitfile, by reconstructing it from layer metadata (filepaths and media types).
There was a problem hiding this comment.
Pull request overview
Adds a fallback path for ModelPack artifacts to synthesize a minimal in-memory Kitfile when the Kitfile JSON annotation is missing, and centralizes the generation logic in pkg/lib/repo/util.
Changes:
- Update
GetKitfileForManifestto parse the Kitfile annotation when present, otherwise generate a Kitfile for ModelPacks. - Introduce
GenerateKitfileForModelPackutility and reuse it from unpack logic (removing the local implementation). - Add unit tests for the ModelPack Kitfile generation utility.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| pkg/lib/repo/util/reference.go | Uses ModelPack annotation Kitfile when available; otherwise calls the shared generator. |
| pkg/lib/repo/util/modelpack.go | New shared utility to synthesize a minimal Kitfile from ModelPack layer metadata. |
| pkg/lib/repo/util/modelpack_test.go | New unit tests validating basic generation and missing-path error behavior. |
| pkg/lib/filesystem/unpack/core.go | Switches unpack fallback logic to call the shared ModelPack generator and removes the local generator. |
Comments suppressed due to low confidence (1)
pkg/lib/filesystem/unpack/core.go:102
- With GetKitfileForManifest now generating a Kitfile for ModelPacks when the annotation is missing, the ErrNoKitfile branch here is unlikely to run for ModelPacks anymore. That means the warning logs and the explicit fallback path may become dead/duplicated logic and the synthesized Kitfile will flow through the "legitimate Kitfile" path (including potentially unpacking a generated Kitfile to disk). Consider simplifying this block, or explicitly detecting the "generated" case so unpack can still warn/avoid writing a synthesized Kitfile if that’s not desired.
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 := util.GenerateKitfileForModelPack(manifest)
if err != nil {
return fmt.Errorf("could not process manifest: %w", err)
}
config = genconfig
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return nil, fmt.Errorf("not a modelpack artifact") | ||
| } | ||
| kf := &artifact.KitFile{ | ||
| Model: &artifact.Model{}, |
There was a problem hiding this comment.
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.
| Model: &artifact.Model{}, | |
| ManifestVersion: "1.0.0", | |
| Model: &artifact.Model{}, |
| 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) |
There was a problem hiding this comment.
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.
| return nil, fmt.Errorf("failed to parse config: %w", err) | |
| return nil, fmt.Errorf("failed to parse Kitfile JSON annotation: %w", err) |
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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}) |
There was a problem hiding this comment.
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).
| 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}) | |
| } |
| case mediatype.DatasetBaseType: | ||
| kf.DataSets = append(kf.DataSets, artifact.DataSet{Path: filepath}) | ||
| case mediatype.DocsBaseType: | ||
| kf.Docs = append(kf.Docs, artifact.Docs{Path: filepath}) |
There was a problem hiding this comment.
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.
| 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. |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
|
Can you add the sign-off messages to your commits. Click on the failing DCO check above for how to instructions. |
Use fallback generation for missing Kitfile in ModelPacks.
Description
This PR introduces a fallback mechanism to generate a Kitfile in-memory when a ModelPack manifest does not contain one in its annotations.
Previously, if the
org.cncf.model.kitfileannotation was missing,GetKitfileForManifestwould returnErrNoKitfile. This change addsGenerateKitfileForModelPackto reconstruct the Kitfile from layer metadata (filepaths and media types), allowing tools to inspect and unpack these artifacts seamlessy.Validation:
pkg/lib/repo/util/modelpack_test.gopkg/lib/filesystem/unpack/core.gonow uses the shared utility.Linked issues
Fixes missing Kitfile handling for ModelPacks.