diff --git a/cmd/cli/commands/integration_test.go b/cmd/cli/commands/integration_test.go index 0bda81091..413be2fc5 100644 --- a/cmd/cli/commands/integration_test.go +++ b/cmd/cli/commands/integration_test.go @@ -269,7 +269,8 @@ func createAndPushTestModel(t *testing.T, registryURL, modelRef string, contextS // Set context size if specified if contextSize != nil { - pkg = pkg.WithContextSize(*contextSize) + pkg, err = pkg.WithContextSize(*contextSize) + require.NoError(t, err) } // Construct the full reference with the local registry host for pushing from test host @@ -1053,6 +1054,7 @@ func TestIntegration_PackageModel(t *testing.T) { opts := packageOptions{ ggufPath: absPath, tag: targetTag, + format: "docker", } // Execute the package command using the helper function with test client @@ -1088,6 +1090,7 @@ func TestIntegration_PackageModel(t *testing.T) { ggufPath: absPath, tag: targetTag, contextSize: 4096, + format: "docker", } // Create a command for context @@ -1120,6 +1123,7 @@ func TestIntegration_PackageModel(t *testing.T) { opts := packageOptions{ ggufPath: absPath, tag: targetTag, + format: "docker", } // Create a command for context @@ -1142,6 +1146,34 @@ func TestIntegration_PackageModel(t *testing.T) { require.NoError(t, err, "Failed to remove model") }) + // Test case 4: Package with CNCF format + t.Run("package GGUF with CNCF format", func(t *testing.T) { + targetTag := "ai/packaged-cncf:latest" + + // Create package options with CNCF format + opts := packageOptions{ + ggufPath: absPath, + tag: targetTag, + format: "cncf", + } + + // Execute the package command using the helper function with test client + t.Logf("Packaging GGUF file as CNCF format %s", targetTag) + err := packageModel(env.ctx, newPackagedCmd(), env.client, opts) + require.NoError(t, err, "Failed to package GGUF model with CNCF format") + + // Verify the model was loaded and tagged + model, err := env.client.Inspect(targetTag, false) + require.NoError(t, err, "Failed to inspect CNCF packaged model") + require.Contains(t, model.Tags, normalizeRef(t, targetTag), "Model should have the expected tag") + + t.Logf("✓ Successfully packaged model with CNCF format: %s (ID: %s)", targetTag, model.ID[7:19]) + + // Cleanup + err = removeModel(env.client, model.ID, true) + require.NoError(t, err, "Failed to remove model") + }) + // Verify all models are cleaned up models, err = listModels(false, env.client, true, false, "") require.NoError(t, err) diff --git a/cmd/cli/commands/package.go b/cmd/cli/commands/package.go index d1c8dcdab..c0d78bbd8 100644 --- a/cmd/cli/commands/package.go +++ b/cmd/cli/commands/package.go @@ -208,6 +208,8 @@ Packaging behavior: c.Flags().StringVar(&opts.mmprojPath, "mmproj", "", "absolute path to multimodal projector file") c.Flags().BoolVar(&opts.push, "push", false, "push to registry (if not set, the model is loaded into the Model Runner content store)") c.Flags().Uint64Var(&opts.contextSize, "context-size", 0, "context size in tokens") + c.Flags().StringVar(&opts.format, "format", "docker", + "output artifact format: \"docker\" (default) or \"cncf\" (CNCF ModelPack spec)") return c } @@ -222,21 +224,36 @@ type packageOptions struct { mmprojPath string push bool tag string + format string // "docker" (default) or "cncf" } -// builderInitResult contains the result of initializing a builder from various sources +// builderInitResult contains the result of initializing a builder from +// various sources. type builderInitResult struct { builder *builder.Builder - distClient *distribution.Client // Only set when building from existing model - cleanupFunc func() // Optional cleanup function for temporary files + distClient *distribution.Client // Only set when building from existing model. + cleanupFunc func() // Optional cleanup function for temporary files. } -// initializeBuilder creates a package builder from GGUF, Safetensors, DDUF, or existing model +// initializeBuilder creates a package builder from GGUF, Safetensors, DDUF, +// or existing model. func initializeBuilder(ctx context.Context, cmd *cobra.Command, client *desktop.Client, opts packageOptions) (*builderInitResult, error) { result := &builderInitResult{} + // Only pass format option to the builder if the --format flag was + // explicitly set by the user. When omitted, the builder inherits + // the source model's format automatically (see builder.FromModel). + var buildOpts []builder.BuildOption + if cmd.Flags().Changed("format") { + buildFmt := builder.BuildFormatDocker + if opts.format == "cncf" { + buildFmt = builder.BuildFormatCNCF + } + buildOpts = append(buildOpts, builder.WithFormat(buildFmt)) + } + if opts.fromModel != "" { - // Get the model store path + // Get the model store path. userHomeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("get user home directory: %w", err) @@ -246,14 +263,14 @@ func initializeBuilder(ctx context.Context, cmd *cobra.Command, client *desktop. modelStorePath = envPath } - // Create a distribution client to access the model store + // Create a distribution client to access the model store. distClient, err := distribution.NewClient(distribution.WithStoreRootPath(modelStorePath)) if err != nil { return nil, fmt.Errorf("create distribution client: %w", err) } result.distClient = distClient - // Package from existing model + // Package from existing model. cmd.PrintErrf("Reading model from store: %q\n", opts.fromModel) mdl, err := distClient.GetModel(opts.fromModel) @@ -266,35 +283,44 @@ func initializeBuilder(ctx context.Context, cmd *cobra.Command, client *desktop. } } - // Type assert to ModelArtifact - the Model from store implements both interfaces + // Type assert to ModelArtifact. modelArtifact, ok := mdl.(types.ModelArtifact) if !ok { return nil, fmt.Errorf("model does not implement ModelArtifact interface") } cmd.PrintErrf("Creating builder from existing model\n") - result.builder, err = builder.FromModel(modelArtifact) + result.builder, err = builder.FromModel(modelArtifact, buildOpts...) if err != nil { return nil, fmt.Errorf("create builder from model: %w", err) } } else if opts.ggufPath != "" { cmd.PrintErrf("Adding GGUF file from %q\n", opts.ggufPath) - pkg, err := builder.FromPath(opts.ggufPath) + pkg, err := builder.FromPath(opts.ggufPath, buildOpts...) if err != nil { return nil, fmt.Errorf("add gguf file: %w", err) } result.builder = pkg } else if opts.ddufPath != "" { cmd.PrintErrf("Adding DDUF file from %q\n", opts.ddufPath) - pkg, err := builder.FromPath(opts.ddufPath) + pkg, err := builder.FromPath(opts.ddufPath, buildOpts...) if err != nil { return nil, fmt.Errorf("add dduf file: %w", err) } result.builder = pkg } else if opts.safetensorsDir != "" { - // Safetensors model from directory — uses V0.2 layer-per-file packaging + // Safetensors model from directory — uses V0.2 layer-per-file packaging. cmd.PrintErrf("Scanning directory %q for safetensors model...\n", opts.safetensorsDir) - pkg, err := builder.FromDirectory(opts.safetensorsDir) + var dirOpts []builder.DirectoryOption + if cmd.Flags().Changed("format") { + dirFmt := builder.BuildFormatDocker + if opts.format == "cncf" { + dirFmt = builder.BuildFormatCNCF + } + dirOpts = append(dirOpts, builder.WithOutputFormat(dirFmt)) + } + pkg, err := builder.FromDirectory(opts.safetensorsDir, + dirOpts...) if err != nil { return nil, fmt.Errorf("create safetensors model from directory: %w", err) } @@ -344,9 +370,17 @@ func fetchModelFromDaemon(ctx context.Context, cmd *cobra.Command, client *deskt } func packageModel(ctx context.Context, cmd *cobra.Command, client *desktop.Client, opts packageOptions) error { - // Use daemon-side repackaging for simple config-only changes (no new layers) + // Validate format flag. + if opts.format != "docker" && opts.format != "cncf" { + return fmt.Errorf("invalid --format value %q: must be \"docker\" or \"cncf\"", opts.format) + } + + // Use daemon-side repackaging for simple config-only changes (no new + // layers). Disabled for CNCF format because the daemon produces + // Docker-format artifacts. canUseDaemonRepackage := opts.fromModel != "" && !opts.push && + opts.format != "cncf" && len(opts.licensePaths) == 0 && opts.chatTemplatePath == "" && opts.mmprojPath == "" && @@ -408,7 +442,10 @@ func packageModel(ctx context.Context, cmd *cobra.Command, client *desktop.Clien // Set context size if cmd.Flags().Changed("context-size") { cmd.PrintErrf("Setting context size %d\n", opts.contextSize) - pkg = pkg.WithContextSize(int32(opts.contextSize)) + pkg, err = pkg.WithContextSize(int32(opts.contextSize)) + if err != nil { + return err + } } // Add license files diff --git a/cmd/cli/docs/reference/docker_model_package.yaml b/cmd/cli/docs/reference/docker_model_package.yaml index ce868c1e5..7bc696c5b 100644 --- a/cmd/cli/docs/reference/docker_model_package.yaml +++ b/cmd/cli/docs/reference/docker_model_package.yaml @@ -71,6 +71,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: format + value_type: string + default_value: docker + description: | + output artifact format: "docker" (default) or "cncf" (CNCF ModelPack spec) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: from value_type: string description: reference to an existing model to repackage diff --git a/cmd/cli/docs/reference/model_package.md b/cmd/cli/docs/reference/model_package.md index ade44149b..571b77c1f 100644 --- a/cmd/cli/docs/reference/model_package.md +++ b/cmd/cli/docs/reference/model_package.md @@ -42,17 +42,18 @@ Packaging behavior: ### Options -| Name | Type | Default | Description | -|:--------------------|:--------------|:--------|:---------------------------------------------------------------------------------------| -| `--chat-template` | `string` | | absolute path to chat template file (must be Jinja format) | -| `--context-size` | `uint64` | `0` | context size in tokens | -| `--dduf` | `string` | | absolute path to DDUF archive file (Diffusers Unified Format) | -| `--from` | `string` | | reference to an existing model to repackage | -| `--gguf` | `string` | | absolute path to gguf file | -| `-l`, `--license` | `stringArray` | | absolute path to a license file | -| `--mmproj` | `string` | | absolute path to multimodal projector file | -| `--push` | `bool` | | push to registry (if not set, the model is loaded into the Model Runner content store) | -| `--safetensors-dir` | `string` | | absolute path to directory containing safetensors files and config | +| Name | Type | Default | Description | +|:--------------------|:--------------|:---------|:---------------------------------------------------------------------------------------| +| `--chat-template` | `string` | | absolute path to chat template file (must be Jinja format) | +| `--context-size` | `uint64` | `0` | context size in tokens | +| `--dduf` | `string` | | absolute path to DDUF archive file (Diffusers Unified Format) | +| `--format` | `string` | `docker` | output artifact format: "docker" (default) or "cncf" (CNCF ModelPack spec) | +| `--from` | `string` | | reference to an existing model to repackage | +| `--gguf` | `string` | | absolute path to gguf file | +| `-l`, `--license` | `stringArray` | | absolute path to a license file | +| `--mmproj` | `string` | | absolute path to multimodal projector file | +| `--push` | `bool` | | push to registry (if not set, the model is loaded into the Model Runner content store) | +| `--safetensors-dir` | `string` | | absolute path to directory containing safetensors files and config | diff --git a/pkg/distribution/builder/builder.go b/pkg/distribution/builder/builder.go index d306b12fb..ed8bb5b08 100644 --- a/pkg/distribution/builder/builder.go +++ b/pkg/distribution/builder/builder.go @@ -2,6 +2,7 @@ package builder import ( "context" + "encoding/json" "fmt" "io" "time" @@ -9,8 +10,22 @@ import ( "github.com/docker/model-runner/pkg/distribution/format" "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/modelpack" "github.com/docker/model-runner/pkg/distribution/oci" "github.com/docker/model-runner/pkg/distribution/types" + "github.com/opencontainers/go-digest" +) + +// BuildFormat specifies the output artifact format. +type BuildFormat string + +const ( + // BuildFormatDocker produces Docker-proprietary format artifacts + // (application/vnd.docker.ai.* media types). This is the default. + BuildFormatDocker BuildFormat = "docker" + // BuildFormatCNCF produces CNCF ModelPack format artifacts + // (application/vnd.cncf.model.* media types). + BuildFormatCNCF BuildFormat = "cncf" ) // BuildOption configures the behavior of FromPath and FromPaths. @@ -18,6 +33,7 @@ type BuildOption func(*buildOptions) type buildOptions struct { created *time.Time + format BuildFormat } // WithCreated sets a specific creation timestamp for the model artifact. @@ -30,10 +46,18 @@ func WithCreated(t time.Time) BuildOption { } } -// Builder builds a model artifact +// WithFormat sets the output artifact format. Defaults to BuildFormatDocker. +func WithFormat(f BuildFormat) BuildOption { + return func(opts *buildOptions) { + opts.format = f + } +} + +// Builder builds a model artifact. type Builder struct { model types.ModelArtifact - originalLayers []oci.Layer // Snapshot of layers when created from existing model + originalLayers []oci.Layer // Snapshot of layers when created from existing model. + outputFormat BuildFormat // Output artifact format (docker or cncf). } // FromPath returns a *Builder that builds model artifacts from a file path. @@ -81,7 +105,8 @@ func fromFormat(f format.Format, paths []string, opts ...BuildOption) (*Builder, opt(options) } - // Create layers from paths + // Create layers from paths using the Docker media type initially. + // For CNCF output, media types are remapped below. layers := make([]oci.Layer, len(paths)) diffIDs := make([]oci.Hash, len(paths)) @@ -99,13 +124,13 @@ func fromFormat(f format.Format, paths []string, opts ...BuildOption) (*Builder, diffIDs[i] = diffID } - // Extract config metadata using format-specific logic + // Extract config metadata using format-specific logic. config, err := f.ExtractConfig(paths) if err != nil { return nil, fmt.Errorf("extract config: %w", err) } - // Use the provided creation time, or fall back to current time + // Use the provided creation time, or fall back to current time. var created time.Time if options.created != nil { created = *options.created @@ -113,7 +138,11 @@ func fromFormat(f format.Format, paths []string, opts ...BuildOption) (*Builder, created = time.Now() } - // Build the model + if options.format == BuildFormatCNCF { + return fromFormatCNCF(config, layers, diffIDs, types.Descriptor{Created: &created}) + } + + // Build the Docker-format model (default). mdl := &partial.BaseModel{ ModelConfigFile: types.ConfigFile{ Config: config, @@ -129,69 +158,306 @@ func fromFormat(f format.Format, paths []string, opts ...BuildOption) (*Builder, } return &Builder{ - model: mdl, + model: mdl, + outputFormat: BuildFormatDocker, + }, nil +} + +// fromFormatCNCF builds a CNCFModel from format-extracted config and layers. +func fromFormatCNCF( + config types.Config, + layers []oci.Layer, + diffIDs []oci.Hash, + desc types.Descriptor, +) (*Builder, error) { + // Convert DiffIDs from oci.Hash to digest.Digest. + cncfDiffIDs := make([]digest.Digest, len(diffIDs)) + for i, d := range diffIDs { + cncfDiffIDs[i] = digest.Digest(d.String()) + } + + // Remap layer media types to CNCF. + cncfLayers := make([]oci.Layer, len(layers)) + for i, l := range layers { + mt, err := l.MediaType() + if err != nil { + return nil, fmt.Errorf("get layer media type: %w", err) + } + fp := layerFilePath(l) + cncfMT := modelpack.MapLayerMediaType(mt, fp) + rl, err := newRemappedLayer(l, cncfMT) + if err != nil { + return nil, fmt.Errorf("remap layer %d: %w", i, err) + } + cncfLayers[i] = rl + } + + mp := modelpack.DockerConfigToModelPack(config, desc, cncfDiffIDs) + mdl := &partial.CNCFModel{ + ModelPackConfig: mp, + LayerList: cncfLayers, + } + return &Builder{ + model: mdl, + outputFormat: BuildFormatCNCF, }, nil } -// FromModel returns a *Builder that builds model artifacts from an existing model artifact -func FromModel(mdl types.ModelArtifact) (*Builder, error) { - // Capture original layers for comparison +// layerFilePath extracts the filepath annotation from a layer, if present. +func layerFilePath(l oci.Layer) string { + type descriptorProvider interface { + GetDescriptor() oci.Descriptor + } + if dp, ok := l.(descriptorProvider); ok { + if fp, ok := dp.GetDescriptor().Annotations[types.AnnotationFilePath]; ok { + return fp + } + } + return "" +} + +// remappedLayer wraps an existing layer and overrides its media type. +// Digest and size are pre-computed at construction time so that +// GetDescriptor never silently swallows errors. +type remappedLayer struct { + oci.Layer + newMediaType oci.MediaType + cachedDigest oci.Hash + cachedSize int64 +} + +// newRemappedLayer creates a remappedLayer, eagerly resolving digest and size +// so that any error (e.g. network failure on a remote layer) surfaces at +// build time rather than producing an invalid OCI descriptor later. +func newRemappedLayer(l oci.Layer, mt oci.MediaType) (*remappedLayer, error) { + d, err := l.Digest() + if err != nil { + return nil, fmt.Errorf("get layer digest: %w", err) + } + s, err := l.Size() + if err != nil { + return nil, fmt.Errorf("get layer size: %w", err) + } + return &remappedLayer{ + Layer: l, + newMediaType: mt, + cachedDigest: d, + cachedSize: s, + }, nil +} + +// MediaType returns the remapped media type. +func (r *remappedLayer) MediaType() (oci.MediaType, error) { + return r.newMediaType, nil +} + +// GetDescriptor returns a copy of the underlying descriptor with the +// overridden media type. +func (r *remappedLayer) GetDescriptor() oci.Descriptor { + type descriptorProvider interface { + GetDescriptor() oci.Descriptor + } + var desc oci.Descriptor + if dp, ok := r.Layer.(descriptorProvider); ok { + desc = dp.GetDescriptor() + } else { + // Use pre-computed values for layers that are not descriptor + // providers (e.g. remoteLayer). Errors were already checked in + // newRemappedLayer. + desc = oci.Descriptor{Digest: r.cachedDigest, Size: r.cachedSize} + } + desc.MediaType = r.newMediaType + return desc +} + +// FromModel returns a *Builder that builds model artifacts from an existing +// model artifact. When WithFormat is provided, the output uses that format. +// When no format is specified, the builder inherits the source model's format +// (auto-detecting CNCF ModelPack via the config). This prevents accidentally +// producing inconsistent artifacts when repackaging a CNCF model without an +// explicit --format flag. +func FromModel(mdl types.ModelArtifact, opts ...BuildOption) (*Builder, error) { + options := &buildOptions{} + for _, opt := range opts { + opt(options) + } + + // Capture original layers for comparison. layers, err := mdl.Layers() if err != nil { return nil, fmt.Errorf("getting model layers: %w", err) } + + // Determine output format. If not explicitly set, detect from the model. + outFmt := options.format + if outFmt == "" { + rawCfg, err := mdl.RawConfigFile() + if err != nil { + return nil, fmt.Errorf("get raw config for format detection: %w", err) + } + if modelpack.IsModelPackConfig(rawCfg) { + outFmt = BuildFormatCNCF + } else { + outFmt = BuildFormatDocker + } + } + + if outFmt == BuildFormatCNCF { + // Convert the source artifact eagerly to CNCF format. This is + // necessary because mutations (WithLicense, etc.) and lightweight + // repackaging both operate on the builder state before Build(). + cncfMdl, err := convertToCNCF(mdl) + if err != nil { + return nil, fmt.Errorf("convert to cncf format: %w", err) + } + return &Builder{ + model: cncfMdl, + originalLayers: layers, + outputFormat: BuildFormatCNCF, + }, nil + } + return &Builder{ model: mdl, originalLayers: layers, + outputFormat: BuildFormatDocker, + }, nil +} + +// convertToCNCF converts an existing model artifact to a CNCFModel. It remaps +// all layer media types and converts the config to CNCF ModelPack format. +func convertToCNCF(mdl types.ModelArtifact) (*partial.CNCFModel, error) { + layers, err := mdl.Layers() + if err != nil { + return nil, fmt.Errorf("get layers: %w", err) + } + + // Get the Docker-format config. + rawCfg, err := mdl.RawConfigFile() + if err != nil { + return nil, fmt.Errorf("get raw config: %w", err) + } + + // Remap layer media types and collect DiffIDs. + cncfLayers := make([]oci.Layer, len(layers)) + diffIDs := make([]digest.Digest, len(layers)) + for i, l := range layers { + mt, err := l.MediaType() + if err != nil { + return nil, fmt.Errorf("get layer media type: %w", err) + } + fp := layerFilePath(l) + cncfMT := modelpack.MapLayerMediaType(mt, fp) + rl, err := newRemappedLayer(l, cncfMT) + if err != nil { + return nil, fmt.Errorf("remap layer %d: %w", i, err) + } + cncfLayers[i] = rl + + diffID, err := l.DiffID() + if err != nil { + return nil, fmt.Errorf("get layer diffID: %w", err) + } + diffIDs[i] = digest.Digest(diffID.String()) + } + + // Build the CNCF config. If the source is already ModelPack format, use + // it directly (updating the DiffIDs from current layers). Otherwise + // convert from Docker format. + var mp modelpack.Model + if modelpack.IsModelPackConfig(rawCfg) { + if err := json.Unmarshal(rawCfg, &mp); err != nil { + return nil, fmt.Errorf("unmarshal modelpack config: %w", err) + } + mp.ModelFS.DiffIDs = diffIDs + } else { + var cf types.ConfigFile + if err := json.Unmarshal(rawCfg, &cf); err != nil { + return nil, fmt.Errorf("unmarshal docker config: %w", err) + } + mp = modelpack.DockerConfigToModelPack(cf.Config, cf.Descriptor, diffIDs) + } + + return &partial.CNCFModel{ + ModelPackConfig: mp, + LayerList: cncfLayers, }, nil } -// WithLicense adds a license file to the artifact +// resolveLayerMediaType returns the appropriate media type for an additional +// layer based on the builder's output format. For CNCF format, Docker media +// types are remapped to their CNCF equivalents. +func (b *Builder) resolveLayerMediaType(dockerMT oci.MediaType) oci.MediaType { + if b.outputFormat == BuildFormatCNCF { + return modelpack.MapLayerMediaType(dockerMT, "") + } + return dockerMT +} + +// WithLicense adds a license file to the artifact. func (b *Builder) WithLicense(path string) (*Builder, error) { - licenseLayer, err := partial.NewLayer(path, types.MediaTypeLicense) + mt := b.resolveLayerMediaType(types.MediaTypeLicense) + licenseLayer, err := partial.NewLayer(path, mt) if err != nil { return nil, fmt.Errorf("license layer from %q: %w", path, err) } return &Builder{ model: mutate.AppendLayers(b.model, licenseLayer), originalLayers: b.originalLayers, + outputFormat: b.outputFormat, }, nil } -func (b *Builder) WithContextSize(size int32) *Builder { +// WithContextSize sets the context size for the model artifact. +// Returns an error when the output format is CNCF (context size is not +// defined in the CNCF ModelPack specification). +func (b *Builder) WithContextSize(size int32) (*Builder, error) { + if b.outputFormat == BuildFormatCNCF { + return nil, fmt.Errorf( + "--context-size is not supported with --format cncf: " + + "the CNCF ModelPack specification does not define a context " + + "size field", + ) + } return &Builder{ model: mutate.ContextSize(b.model, size), originalLayers: b.originalLayers, - } + outputFormat: b.outputFormat, + }, nil } -// WithMultimodalProjector adds a Multimodal projector file to the artifact +// WithMultimodalProjector adds a multimodal projector file to the artifact. func (b *Builder) WithMultimodalProjector(path string) (*Builder, error) { - mmprojLayer, err := partial.NewLayer(path, types.MediaTypeMultimodalProjector) + mt := b.resolveLayerMediaType(types.MediaTypeMultimodalProjector) + mmprojLayer, err := partial.NewLayer(path, mt) if err != nil { return nil, fmt.Errorf("mmproj layer from %q: %w", path, err) } return &Builder{ model: mutate.AppendLayers(b.model, mmprojLayer), originalLayers: b.originalLayers, + outputFormat: b.outputFormat, }, nil } -// WithChatTemplateFile adds a Jinja chat template file to the artifact which takes precedence over template from GGUF. +// WithChatTemplateFile adds a Jinja chat template file to the artifact, +// taking precedence over any template embedded in the GGUF file. func (b *Builder) WithChatTemplateFile(path string) (*Builder, error) { - templateLayer, err := partial.NewLayer(path, types.MediaTypeChatTemplate) + mt := b.resolveLayerMediaType(types.MediaTypeChatTemplate) + templateLayer, err := partial.NewLayer(path, mt) if err != nil { return nil, fmt.Errorf("chat template layer from %q: %w", path, err) } return &Builder{ model: mutate.AppendLayers(b.model, templateLayer), originalLayers: b.originalLayers, + outputFormat: b.outputFormat, }, nil } -// WithConfigArchive adds a config archive (tar) file to the artifact +// WithConfigArchive adds a config archive (tar) file to the artifact. func (b *Builder) WithConfigArchive(path string) (*Builder, error) { - // Check if config archive already exists + // Check if config archive already exists. layers, err := b.model.Layers() if err != nil { return nil, fmt.Errorf("get model layers: %w", err) @@ -204,13 +470,15 @@ func (b *Builder) WithConfigArchive(path string) (*Builder, error) { } } - configLayer, err := partial.NewLayer(path, types.MediaTypeVLLMConfigArchive) + mt := b.resolveLayerMediaType(types.MediaTypeVLLMConfigArchive) + configLayer, err := partial.NewLayer(path, mt) if err != nil { return nil, fmt.Errorf("config archive layer from %q: %w", path, err) } return &Builder{ model: mutate.AppendLayers(b.model, configLayer), originalLayers: b.originalLayers, + outputFormat: b.outputFormat, }, nil } diff --git a/pkg/distribution/builder/builder_test.go b/pkg/distribution/builder/builder_test.go index 1c71cf502..a10311b43 100644 --- a/pkg/distribution/builder/builder_test.go +++ b/pkg/distribution/builder/builder_test.go @@ -2,6 +2,7 @@ package builder_test import ( "context" + "encoding/json" "fmt" "io" "path/filepath" @@ -11,6 +12,8 @@ import ( "github.com/docker/model-runner/pkg/distribution/builder" "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/types" ) @@ -190,7 +193,10 @@ func TestWithMultimodalProjectorChaining(t *testing.T) { t.Fatalf("Failed to add multimodal projector: %v", err) } - b = b.WithContextSize(4096) + b, err = b.WithContextSize(4096) + if err != nil { + t.Fatalf("Failed to set context size: %v", err) + } // Build the model target := &fakeTarget{} @@ -256,7 +262,10 @@ func TestFromModel(t *testing.T) { } // Set initial context size - initialBuilder = initialBuilder.WithContextSize(2048) + initialBuilder, err = initialBuilder.WithContextSize(2048) + if err != nil { + t.Fatalf("Failed to set context size: %v", err) + } // Build the initial model initialTarget := &fakeTarget{} @@ -280,7 +289,10 @@ func TestFromModel(t *testing.T) { } // Step 3: Modify the context size to 4096 - repackagedBuilder = repackagedBuilder.WithContextSize(4096) + repackagedBuilder, err = repackagedBuilder.WithContextSize(4096) + if err != nil { + t.Fatalf("Failed to set context size: %v", err) + } // Step 4: Build the repackaged model repackagedTarget := &fakeTarget{} @@ -413,6 +425,336 @@ func TestFromModelErrorHandling(t *testing.T) { } } +// TestFromPathCNCFFormat verifies that FromPath with WithFormat(BuildFormatCNCF) produces +// a valid CNCF ModelPack artifact with correct media types, artifact type, and config. +func TestFromPathCNCFFormat(t *testing.T) { + ggufPath := filepath.Join("..", "assets", "dummy.gguf") + fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + b, err := builder.FromPath(ggufPath, + builder.WithFormat(builder.BuildFormatCNCF), + builder.WithCreated(fixedTime), + ) + if err != nil { + t.Fatalf("FromPath with CNCF format failed: %v", err) + } + + target := &fakeTarget{} + if err := b.Build(t.Context(), target, nil); err != nil { + t.Fatalf("Build failed: %v", err) + } + + // 1. Verify manifest has CNCF artifact type. + manifest, err := target.artifact.Manifest() + if err != nil { + t.Fatalf("Failed to get manifest: %v", err) + } + if manifest.ArtifactType != modelpack.ArtifactTypeModelManifest { + t.Errorf("Expected artifactType %q, got %q", + modelpack.ArtifactTypeModelManifest, manifest.ArtifactType) + } + + // 2. Verify config media type is CNCF model config. + if manifest.Config.MediaType != oci.MediaType(modelpack.MediaTypeModelConfigV1) { + t.Errorf("Expected config media type %q, got %q", + modelpack.MediaTypeModelConfigV1, manifest.Config.MediaType) + } + + // 3. Verify all layers have CNCF media types (not Docker media types). + for i, layer := range manifest.Layers { + mt := string(layer.MediaType) + if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) { + t.Errorf("Layer %d has non-CNCF media type %q (expected prefix %q)", + i, mt, modelpack.MediaTypePrefix) + } + } + + // 4. Verify the weight layer specifically uses the CNCF weight media type. + if len(manifest.Layers) == 0 { + t.Fatal("Expected at least one layer") + } + weightMT := manifest.Layers[0].MediaType + if weightMT != oci.MediaType(modelpack.MediaTypeWeightRaw) { + t.Errorf("Expected weight layer media type %q, got %q", + modelpack.MediaTypeWeightRaw, weightMT) + } + + // 5. Verify the raw config is valid ModelPack JSON with correct fields. + rawCfg, err := target.artifact.RawConfigFile() + if err != nil { + t.Fatalf("Failed to get raw config: %v", err) + } + var mp modelpack.Model + if err := json.Unmarshal(rawCfg, &mp); err != nil { + t.Fatalf("Failed to unmarshal CNCF config: %v", err) + } + if mp.Config.Format != "gguf" { + t.Errorf("Expected config.format %q, got %q", "gguf", mp.Config.Format) + } + if mp.ModelFS.Type != "layers" { + t.Errorf("Expected modelfs.type %q, got %q", "layers", mp.ModelFS.Type) + } + if len(mp.ModelFS.DiffIDs) == 0 { + t.Error("Expected at least one diffId in modelfs") + } + if mp.Descriptor.CreatedAt == nil { + t.Error("Expected descriptor.createdAt to be set") + } else if !mp.Descriptor.CreatedAt.Equal(fixedTime) { + t.Errorf("Expected descriptor.createdAt %v, got %v", fixedTime, *mp.Descriptor.CreatedAt) + } + + // 6. Verify the JSON tags are camelCase (spec-compliant). + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(rawCfg, &rawMap); err != nil { + t.Fatalf("Failed to unmarshal config to map: %v", err) + } + // Must have "modelfs" (not "model_fs"). + if _, ok := rawMap["modelfs"]; !ok { + t.Error("Config JSON missing 'modelfs' key") + } + // Verify modelfs contains "diffIds" (camelCase, not "diff_ids"). + if modelfsRaw, ok := rawMap["modelfs"]; ok { + var modelfsMap map[string]json.RawMessage + if err := json.Unmarshal(modelfsRaw, &modelfsMap); err != nil { + t.Fatalf("Failed to unmarshal modelfs: %v", err) + } + if _, ok := modelfsMap["diffIds"]; !ok { + t.Error("modelfs JSON missing 'diffIds' key (expected camelCase)") + } + if _, ok := modelfsMap["diff_ids"]; ok { + t.Error("modelfs JSON has 'diff_ids' (snake_case) — should be 'diffIds' (camelCase)") + } + } + // Verify config contains "paramSize" (not "param_size"). + if configRaw, ok := rawMap["config"]; ok { + var configMap map[string]json.RawMessage + if err := json.Unmarshal(configRaw, &configMap); err != nil { + t.Fatalf("Failed to unmarshal config section: %v", err) + } + if _, ok := configMap["param_size"]; ok { + t.Error("config JSON has 'param_size' (snake_case) — should be 'paramSize' (camelCase)") + } + } +} + +// TestFromPathCNCFWithAdditionalLayers verifies that additional layers added +// to a CNCF builder get CNCF media types, not Docker media types. +func TestFromPathCNCFWithAdditionalLayers(t *testing.T) { + ggufPath := filepath.Join("..", "assets", "dummy.gguf") + + b, err := builder.FromPath(ggufPath, builder.WithFormat(builder.BuildFormatCNCF)) + if err != nil { + t.Fatalf("FromPath failed: %v", err) + } + + // Add license + b, err = b.WithLicense(filepath.Join("..", "assets", "license.txt")) + if err != nil { + t.Fatalf("Failed to add license: %v", err) + } + + // Add multimodal projector + b, err = b.WithMultimodalProjector(filepath.Join("..", "assets", "dummy.mmproj")) + if err != nil { + t.Fatalf("Failed to add multimodal projector: %v", err) + } + + // Add chat template + b, err = b.WithChatTemplateFile(filepath.Join("..", "assets", "template.jinja")) + if err != nil { + t.Fatalf("Failed to add chat template: %v", err) + } + + target := &fakeTarget{} + if err := b.Build(t.Context(), target, nil); err != nil { + t.Fatalf("Build failed: %v", err) + } + + manifest, err := target.artifact.Manifest() + if err != nil { + t.Fatalf("Failed to get manifest: %v", err) + } + + // Should have 4 layers: weight + license + mmproj + chat template + if len(manifest.Layers) != 4 { + t.Fatalf("Expected 4 layers, got %d", len(manifest.Layers)) + } + + // ALL layers must have CNCF media type prefix. + for i, layer := range manifest.Layers { + mt := string(layer.MediaType) + if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) { + t.Errorf("Layer %d has non-CNCF media type %q", i, mt) + } + } + + // No Docker media types should appear. + dockerMTs := []oci.MediaType{ + types.MediaTypeGGUF, + types.MediaTypeLicense, + types.MediaTypeMultimodalProjector, + types.MediaTypeChatTemplate, + } + for _, layer := range manifest.Layers { + for _, dmt := range dockerMTs { + if layer.MediaType == dmt { + t.Errorf("Found Docker media type %q in CNCF artifact", dmt) + } + } + } +} + +// TestFromPathCNCFContextSizeError verifies that WithContextSize returns an error +// when the output format is CNCF (context size is not in the CNCF spec). +func TestFromPathCNCFContextSizeError(t *testing.T) { + ggufPath := filepath.Join("..", "assets", "dummy.gguf") + + b, err := builder.FromPath(ggufPath, builder.WithFormat(builder.BuildFormatCNCF)) + if err != nil { + t.Fatalf("FromPath failed: %v", err) + } + + _, err = b.WithContextSize(4096) + if err == nil { + t.Fatal("Expected error when setting context size with CNCF format, got nil") + } + if !strings.Contains(err.Error(), "--context-size is not supported") { + t.Errorf("Expected error about context-size not supported, got: %v", err) + } +} + +// TestFromModelToCNCF verifies that FromModel with WithFormat(BuildFormatCNCF) correctly +// converts a Docker-format model to CNCF ModelPack format. +func TestFromModelToCNCF(t *testing.T) { + // Step 1: Create a Docker-format model with a license layer. + dockerBuilder, err := builder.FromPath(filepath.Join("..", "assets", "dummy.gguf")) + if err != nil { + t.Fatalf("FromPath failed: %v", err) + } + dockerBuilder, err = dockerBuilder.WithLicense(filepath.Join("..", "assets", "license.txt")) + if err != nil { + t.Fatalf("WithLicense failed: %v", err) + } + + dockerTarget := &fakeTarget{} + if err := dockerBuilder.Build(t.Context(), dockerTarget, nil); err != nil { + t.Fatalf("Build Docker model failed: %v", err) + } + + // Verify the Docker model has Docker media types. + dockerManifest, err := dockerTarget.artifact.Manifest() + if err != nil { + t.Fatalf("Failed to get Docker manifest: %v", err) + } + for _, layer := range dockerManifest.Layers { + if strings.HasPrefix(string(layer.MediaType), modelpack.MediaTypePrefix) { + t.Fatalf("Docker model should not have CNCF media types, found %q", layer.MediaType) + } + } + + // Step 2: Convert Docker model to CNCF format. + cncfBuilder, err := builder.FromModel(dockerTarget.artifact, builder.WithFormat(builder.BuildFormatCNCF)) + if err != nil { + t.Fatalf("FromModel with CNCF format failed: %v", err) + } + + cncfTarget := &fakeTarget{} + if err := cncfBuilder.Build(t.Context(), cncfTarget, nil); err != nil { + t.Fatalf("Build CNCF model failed: %v", err) + } + + // Step 3: Verify the CNCF model. + cncfManifest, err := cncfTarget.artifact.Manifest() + if err != nil { + t.Fatalf("Failed to get CNCF manifest: %v", err) + } + + // Artifact type must be set. + if cncfManifest.ArtifactType != modelpack.ArtifactTypeModelManifest { + t.Errorf("Expected artifactType %q, got %q", + modelpack.ArtifactTypeModelManifest, cncfManifest.ArtifactType) + } + + // Config media type must be CNCF. + if cncfManifest.Config.MediaType != oci.MediaType(modelpack.MediaTypeModelConfigV1) { + t.Errorf("Expected config media type %q, got %q", + modelpack.MediaTypeModelConfigV1, cncfManifest.Config.MediaType) + } + + // Same number of layers must be preserved. + if len(cncfManifest.Layers) != len(dockerManifest.Layers) { + t.Fatalf("Expected %d layers, got %d", len(dockerManifest.Layers), len(cncfManifest.Layers)) + } + + // All layers must have CNCF media types. + for i, layer := range cncfManifest.Layers { + mt := string(layer.MediaType) + if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) { + t.Errorf("Layer %d has non-CNCF media type %q after conversion", i, mt) + } + } + + // Layer digests should be preserved (same content, different media type). + for i := range dockerManifest.Layers { + if dockerManifest.Layers[i].Digest != cncfManifest.Layers[i].Digest { + t.Errorf("Layer %d digest changed after conversion: %v → %v", + i, dockerManifest.Layers[i].Digest, cncfManifest.Layers[i].Digest) + } + } + + // Config should have the model architecture and format. + cfg, err := cncfTarget.artifact.Config() + if err != nil { + t.Fatalf("Failed to get config: %v", err) + } + if cfg.GetFormat() != types.FormatGGUF { + t.Errorf("Expected format %q, got %q", types.FormatGGUF, cfg.GetFormat()) + } +} + +// TestFromPathCNCFDeterministicDigest verifies that CNCF format builds +// with the same inputs produce the same digests. +func TestFromPathCNCFDeterministicDigest(t *testing.T) { + ggufPath := filepath.Join("..", "assets", "dummy.gguf") + fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + b1, err := builder.FromPath(ggufPath, + builder.WithFormat(builder.BuildFormatCNCF), + builder.WithCreated(fixedTime), + ) + if err != nil { + t.Fatalf("FromPath (first) failed: %v", err) + } + b2, err := builder.FromPath(ggufPath, + builder.WithFormat(builder.BuildFormatCNCF), + builder.WithCreated(fixedTime), + ) + if err != nil { + t.Fatalf("FromPath (second) failed: %v", err) + } + + target1 := &fakeTarget{} + target2 := &fakeTarget{} + if err := b1.Build(t.Context(), target1, nil); err != nil { + t.Fatalf("Build (first) failed: %v", err) + } + if err := b2.Build(t.Context(), target2, nil); err != nil { + t.Fatalf("Build (second) failed: %v", err) + } + + digest1, err := target1.artifact.Digest() + if err != nil { + t.Fatalf("Digest (first) failed: %v", err) + } + digest2, err := target2.artifact.Digest() + if err != nil { + t.Fatalf("Digest (second) failed: %v", err) + } + if digest1 != digest2 { + t.Errorf("Expected identical digests for CNCF format with same inputs, got %v and %v", digest1, digest2) + } +} + var _ builder.Target = &fakeTarget{} type fakeTarget struct { diff --git a/pkg/distribution/builder/from_directory.go b/pkg/distribution/builder/from_directory.go index 5c7f53a2a..82bc984e2 100644 --- a/pkg/distribution/builder/from_directory.go +++ b/pkg/distribution/builder/from_directory.go @@ -12,8 +12,10 @@ import ( "github.com/docker/model-runner/pkg/distribution/format" "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/modelpack" "github.com/docker/model-runner/pkg/distribution/oci" "github.com/docker/model-runner/pkg/distribution/types" + "github.com/opencontainers/go-digest" ) const rootFSType = "rootfs" @@ -32,6 +34,9 @@ type DirectoryOptions struct { // When set, it overrides the default behavior of using time.Now(). // This is useful for producing deterministic OCI digests. Created *time.Time + + // Format is the output artifact format. Defaults to BuildFormatDocker. + Format BuildFormat } // DirectoryOption is a functional option for configuring FromDirectory. @@ -62,6 +67,15 @@ func WithCreatedTime(t time.Time) DirectoryOption { } } +// WithOutputFormat sets the output artifact format for the directory builder. +// Defaults to BuildFormatDocker if not specified. +// This is the DirectoryOption equivalent of WithFormat (BuildOption). +func WithOutputFormat(f BuildFormat) DirectoryOption { + return func(opts *DirectoryOptions) { + opts.Format = f + } +} + // FromDirectory creates a Builder from a directory containing model files. // It recursively scans the directory and adds each non-hidden file as a separate layer. // Each layer's filepath annotation preserves the relative path from the directory root. @@ -232,7 +246,35 @@ func FromDirectory(dirPath string, opts ...DirectoryOption) (*Builder, error) { created = time.Now() } - // Build the model with V0.2 config (layer-per-file with annotations) + if options.Format == BuildFormatCNCF { + // Remap layer media types and convert config to CNCF format. + cncfLayers := make([]oci.Layer, len(layers)) + cncfDiffIDs := make([]digest.Digest, len(diffIDs)) + for i, l := range layers { + mt, err := l.MediaType() + if err != nil { + return nil, fmt.Errorf("get layer media type: %w", err) + } + fp := layerFilePath(l) + rl, err := newRemappedLayer(l, modelpack.MapLayerMediaType(mt, fp)) + if err != nil { + return nil, fmt.Errorf("remap layer %d: %w", i, err) + } + cncfLayers[i] = rl + cncfDiffIDs[i] = digest.Digest(diffIDs[i].String()) + } + mp := modelpack.DockerConfigToModelPack( + config, + types.Descriptor{Created: &created}, + cncfDiffIDs, + ) + return &Builder{ + model: &partial.CNCFModel{ModelPackConfig: mp, LayerList: cncfLayers}, + outputFormat: BuildFormatCNCF, + }, nil + } + + // Build the Docker-format model with V0.2 config (layer-per-file with annotations). mdl := &partial.BaseModel{ ModelConfigFile: types.ConfigFile{ Config: config, @@ -249,7 +291,8 @@ func FromDirectory(dirPath string, opts ...DirectoryOption) (*Builder, error) { } return &Builder{ - model: mdl, + model: mdl, + outputFormat: BuildFormatDocker, }, nil }