diff --git a/cmd/cli/commands/modelfile.go b/cmd/cli/commands/modelfile.go new file mode 100644 index 000000000..3c40e8860 --- /dev/null +++ b/cmd/cli/commands/modelfile.go @@ -0,0 +1,172 @@ +package commands + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// modelfileAliases maps Modelfile instruction aliases to their canonical names. +var modelfileAliases = map[string]string{ + "SAFETENSORS-DIR": "SAFETENSORS_DIR", + "CHAT-TEMPLATE": "CHAT_TEMPLATE", + "MM-PROJ": "MMPROJ", + "CTX": "CONTEXT", + "CONTEXT-SIZE": "CONTEXT", +} + +// modelfilePathInstructions is the set of instructions that take a file or directory path. +var modelfilePathInstructions = map[string]struct{}{ + "GGUF": {}, + "SAFETENSORS_DIR": {}, + "DDUF": {}, + "LICENSE": {}, + "CHAT_TEMPLATE": {}, + "MMPROJ": {}, +} + +// applyModelfile reads opts.modelfile and applies its directives to opts. +// CLI flags take precedence over Modelfile values. +func applyModelfile(opts *packageOptions) error { + if opts.modelfile == "" { + return nil + } + + absModelfile, err := filepath.Abs(opts.modelfile) + if err != nil { + return fmt.Errorf("resolve Modelfile path %q: %w", opts.modelfile, err) + } + baseDir := filepath.Dir(absModelfile) + + f, err := os.Open(absModelfile) + if err != nil { + return fmt.Errorf("open Modelfile %q: %w", opts.modelfile, err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + i := strings.IndexAny(line, " \t") + if i == -1 { + return fmt.Errorf("Modelfile line %d: expected an instruction and a value, got: %q", lineNum, line) + } + + instruction := strings.ToUpper(line[:i]) + if canonical, ok := modelfileAliases[instruction]; ok { + instruction = canonical + } + + value := strings.TrimSpace(line[i:]) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value = value[1 : len(value)-1] + } + + var absPath string + if _, isPath := modelfilePathInstructions[instruction]; isPath { + absPath, err = modelfileResolvePath(value, baseDir) + if err != nil { + return fmt.Errorf("Modelfile line %d: invalid path for %s: %w", lineNum, instruction, err) + } + + info, statErr := os.Stat(absPath) + if statErr != nil { + return fmt.Errorf("Modelfile line %d: path for %s not found: %q", lineNum, instruction, absPath) + } + + switch instruction { + case "SAFETENSORS_DIR": + if !info.IsDir() { + return fmt.Errorf("Modelfile line %d: SAFETENSORS_DIR must be a directory: %q", lineNum, absPath) + } + case "GGUF", "DDUF", "LICENSE", "CHAT_TEMPLATE", "MMPROJ": + if info.IsDir() { + return fmt.Errorf("Modelfile line %d: %s must be a file, not a directory: %q", lineNum, instruction, absPath) + } + } + } + + switch instruction { + // Model sources + case "FROM": + if opts.fromModel == "" { + if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || filepath.IsAbs(value) { + return fmt.Errorf("Modelfile line %d: FROM takes a model reference, not a file path; use GGUF or SAFETENSORS_DIR instead", lineNum) + } + opts.fromModel = value + } + + case "GGUF": + if opts.ggufPath == "" { + opts.ggufPath = absPath + } + + case "SAFETENSORS_DIR": + if opts.safetensorsDir == "" { + opts.safetensorsDir = absPath + } + + case "DDUF": + if opts.ddufPath == "" { + opts.ddufPath = absPath + } + + // Optional assets + case "LICENSE": + if !slices.Contains(opts.licensePaths, absPath) { + opts.licensePaths = append(opts.licensePaths, absPath) + } + + case "CHAT_TEMPLATE": + if opts.chatTemplatePath == "" { + opts.chatTemplatePath = absPath + } + + case "MMPROJ": + if opts.mmprojPath == "" { + opts.mmprojPath = absPath + } + + // Parameters + case "CONTEXT": + if opts.contextSize == 0 { + v, parseErr := strconv.ParseUint(value, 10, 64) + if parseErr != nil || v == 0 { + return fmt.Errorf("Modelfile line %d: invalid CONTEXT value %q: must be a positive integer", lineNum, value) + } + opts.contextSize = v + opts.contextSizeSet = true + } + + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("read Modelfile %q: %w", opts.modelfile, err) + } + + return nil +} + +// modelfileResolvePath resolves path to an absolute path relative to baseDir. +func modelfileResolvePath(path, baseDir string) (string, error) { + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} diff --git a/cmd/cli/commands/modelfile_test.go b/cmd/cli/commands/modelfile_test.go new file mode 100644 index 000000000..ba0f718a7 --- /dev/null +++ b/cmd/cli/commands/modelfile_test.go @@ -0,0 +1,391 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyModelfile_Empty(t *testing.T) { + var opts packageOptions + if err := applyModelfile(&opts); err != nil { + t.Fatalf("empty modelfile path: unexpected error: %v", err) + } +} + +func TestApplyModelfile_MissingFile(t *testing.T) { + opts := packageOptions{modelfile: "/nonexistent/Modelfile"} + if err := applyModelfile(&opts); err == nil { + t.Fatal("expected error for missing Modelfile, got nil") + } +} + +func TestApplyModelfile_FROM(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "FROM myorg/llama3:8b\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.fromModel != "myorg/llama3:8b" { + t.Errorf("fromModel = %q, want %q", opts.fromModel, "myorg/llama3:8b") + } +} + +func TestApplyModelfile_FROM_CLIPrecedence(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "FROM modelfile-model:latest\n") + + opts := packageOptions{ + modelfile: filepath.Join(dir, "Modelfile"), + fromModel: "cli-model:latest", + } + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.fromModel != "cli-model:latest" { + t.Errorf("CLI fromModel should take precedence, got %q", opts.fromModel) + } +} + +func TestApplyModelfile_FROM_PathRejected(t *testing.T) { + cases := []string{ + "FROM ./model.gguf\n", + "FROM ../model.gguf\n", + "FROM /absolute/path/model.gguf\n", + } + for _, c := range cases { + dir := t.TempDir() + writeModelfile(t, dir, c) + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + } +} + +func TestApplyModelfile_GGUF(t *testing.T) { + dir := t.TempDir() + ggufFile := filepath.Join(dir, "model.gguf") + writeFile(t, ggufFile, "fake gguf") + writeModelfile(t, dir, "GGUF ./model.gguf\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.ggufPath != ggufFile { + t.Errorf("ggufPath = %q, want %q", opts.ggufPath, ggufFile) + } +} + +func TestApplyModelfile_GGUF_AbsolutePath(t *testing.T) { + dir := t.TempDir() + ggufFile := filepath.Join(dir, "model.gguf") + writeFile(t, ggufFile, "fake gguf") + writeModelfile(t, dir, "GGUF "+ggufFile+"\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.ggufPath != ggufFile { + t.Errorf("ggufPath = %q, want %q", opts.ggufPath, ggufFile) + } +} + +func TestApplyModelfile_GGUF_IsDir(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "GGUF ./\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestApplyModelfile_SAFETENSORS_DIR(t *testing.T) { + dir := t.TempDir() + modelDir := filepath.Join(dir, "weights") + if err := os.Mkdir(modelDir, 0755); err != nil { + t.Fatal(err) + } + writeModelfile(t, dir, "SAFETENSORS_DIR ./weights\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.safetensorsDir != modelDir { + t.Errorf("safetensorsDir = %q, want %q", opts.safetensorsDir, modelDir) + } +} + +func TestApplyModelfile_SAFETENSORS_DIR_Alias(t *testing.T) { + dir := t.TempDir() + modelDir := filepath.Join(dir, "weights") + if err := os.Mkdir(modelDir, 0755); err != nil { + t.Fatal(err) + } + writeModelfile(t, dir, "SAFETENSORS-DIR ./weights\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.safetensorsDir != modelDir { + t.Errorf("safetensorsDir = %q, want %q", opts.safetensorsDir, modelDir) + } +} + +func TestApplyModelfile_SAFETENSORS_DIR_NotDir(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "notadir.txt") + writeFile(t, f, "contents") + writeModelfile(t, dir, "SAFETENSORS_DIR ./notadir.txt\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestApplyModelfile_LICENSE(t *testing.T) { + dir := t.TempDir() + lic := filepath.Join(dir, "LICENSE") + writeFile(t, lic, "MIT") + writeModelfile(t, dir, "LICENSE ./LICENSE\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.licensePaths) != 1 || opts.licensePaths[0] != lic { + t.Errorf("licensePaths = %v, want [%q]", opts.licensePaths, lic) + } +} + +func TestApplyModelfile_LICENSE_Deduplication(t *testing.T) { + dir := t.TempDir() + lic := filepath.Join(dir, "LICENSE") + writeFile(t, lic, "MIT") + writeModelfile(t, dir, "LICENSE ./LICENSE\nLICENSE ./LICENSE\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.licensePaths) != 1 { + t.Errorf("expected 1 license path after deduplication, got %d", len(opts.licensePaths)) + } +} + +func TestApplyModelfile_CONTEXT(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "CONTEXT 4096\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.contextSize != 4096 { + t.Errorf("contextSize = %d, want 4096", opts.contextSize) + } + if !opts.contextSizeSet { + t.Error("contextSizeSet not set") + } +} + +func TestApplyModelfile_CTX_Alias(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "CTX 2048\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.contextSize != 2048 { + t.Errorf("contextSize = %d, want 2048", opts.contextSize) + } +} + +func TestApplyModelfile_CONTEXT_CLIPrecedence(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "CONTEXT 4096\n") + + opts := packageOptions{ + modelfile: filepath.Join(dir, "Modelfile"), + contextSize: 8192, + } + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.contextSize != 8192 { + t.Errorf("contextSize = %d, want 8192", opts.contextSize) + } + if opts.contextSizeSet { + t.Error("contextSizeSet unexpectedly set") + } +} + +func TestApplyModelfile_CONTEXT_Invalid(t *testing.T) { + tests := []struct { + name string + content string + }{ + {"zero", "CONTEXT 0\n"}, + {"non-integer", "CONTEXT abc\n"}, + {"float", "CONTEXT 4096.5\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, tc.content) + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Fatalf("expected error for CONTEXT %q, got nil", tc.content) + } + }) + } +} + +func TestApplyModelfile_CaseInsensitiveInstructions(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "from myorg/model:latest\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.fromModel != "myorg/model:latest" { + t.Errorf("fromModel = %q, want %q", opts.fromModel, "myorg/model:latest") + } +} + +func TestApplyModelfile_CommentsAndBlankLines(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, `# This is a comment + +FROM myorg/model:latest + +# Another comment +`) + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.fromModel != "myorg/model:latest" { + t.Errorf("fromModel = %q, want %q", opts.fromModel, "myorg/model:latest") + } +} + +func TestApplyModelfile_UnknownInstructionIgnored(t *testing.T) { + dir := t.TempDir() + // PARAMETER and SYSTEM are Ollama Modelfile instructions irrelevant to packaging. + writeModelfile(t, dir, "FROM myorg/model:latest\nPARAMETER temperature 0.7\nSYSTEM You are helpful.\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.fromModel != "myorg/model:latest" { + t.Errorf("fromModel = %q, want %q", opts.fromModel, "myorg/model:latest") + } +} + +func TestApplyModelfile_GGUFPathWithSpaces(t *testing.T) { + dir := t.TempDir() + ggufFile := filepath.Join(dir, "my model.gguf") + writeFile(t, ggufFile, "fake gguf") + writeModelfile(t, dir, "GGUF \"my model.gguf\"\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.ggufPath != ggufFile { + t.Errorf("ggufPath = %q, want %q", opts.ggufPath, ggufFile) + } +} + +func TestApplyModelfile_MissingValue(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "FROM\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Fatal("expected error for instruction without value, got nil") + } +} + +func TestApplyModelfile_PathNotFound(t *testing.T) { + dir := t.TempDir() + writeModelfile(t, dir, "GGUF ./nonexistent.gguf\n") + + opts := packageOptions{modelfile: filepath.Join(dir, "Modelfile")} + if err := applyModelfile(&opts); err == nil { + t.Fatal("expected error for nonexistent path, got nil") + } +} + +func TestModelfileResolvePath(t *testing.T) { + base := "/home/user/project" + tests := []struct { + name string + path string + baseDir string + want string + }{ + { + name: "relative path", + path: "model.gguf", + baseDir: base, + want: "/home/user/project/model.gguf", + }, + { + name: "relative path with subdir", + path: "weights/model.gguf", + baseDir: base, + want: "/home/user/project/weights/model.gguf", + }, + { + name: "absolute path unchanged", + path: "/data/model.gguf", + baseDir: base, + want: "/data/model.gguf", + }, + { + name: "relative path with dots cleaned", + path: "./subdir/../model.gguf", + baseDir: base, + want: "/home/user/project/model.gguf", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := modelfileResolvePath(tc.path, tc.baseDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// writeModelfile writes content to a file named "Modelfile" in dir. +func writeModelfile(t *testing.T, dir, content string) { + t.Helper() + writeFile(t, filepath.Join(dir, "Modelfile"), content) +} + +// writeFile writes content to path, failing the test on error. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write file %q: %v", path, err) + } +} diff --git a/cmd/cli/commands/package.go b/cmd/cli/commands/package.go index c0d78bbd8..c3fb27563 100644 --- a/cmd/cli/commands/package.go +++ b/cmd/cli/commands/package.go @@ -38,7 +38,7 @@ func newPackagedCmd() *cobra.Command { var opts packageOptions c := &cobra.Command{ - Use: "package (--gguf | --safetensors-dir | --dduf | --from ) [--license ...] [--mmproj ] [--context-size ] [--push] MODEL", + Use: "package (--gguf | --safetensors-dir | --dduf | --from | --file ) [--license ...] [--mmproj ] [--context-size ] [--push] MODEL", Short: "Package a model into a Docker Model OCI artifact", Long: `Package a model into a Docker Model OCI artifact. @@ -47,6 +47,7 @@ The model source must be one of: --safetensors-dir A directory containing .safetensors and configuration files --dduf A .dduf (Diffusers Unified Format) archive --from An existing packaged model reference + --file A Modelfile describing the model and its assets By default, the packaged artifact is loaded into the local Model Runner content store. Use --push to publish the model to a registry instead. @@ -77,12 +78,31 @@ Packaging behavior: such as --context-size to create a variant of the original model. Multimodal models - Use --mmproj to include a multimodal projector file.`, + Use --mmproj to include a multimodal projector file. + + Modelfile + --file accepts a path to a Modelfile. Supported instructions: + + FROM existing model reference + GGUF GGUF file + SAFETENSORS_DIR safetensors directory (alias: SAFETENSORS-DIR) + DDUF DDUF archive + LICENSE license file; may appear multiple times + CHAT_TEMPLATE chat template file (alias: CHAT-TEMPLATE) + MMPROJ multimodal projector (alias: MM-PROJ) + CONTEXT context size in tokens (aliases: CTX, CONTEXT-SIZE) + + Paths may be relative (resolved from the Modelfile's directory) or absolute. + CLI flags take precedence over Modelfile values.`, Args: func(cmd *cobra.Command, args []string) error { if err := requireExactArgs(1, "package", "MODEL")(cmd, args); err != nil { return err } + if err := applyModelfile(&opts); err != nil { + return err + } + // Validate that exactly one of --gguf, --safetensors-dir, --dduf, or --from is provided (mutually exclusive) sourcesProvided := 0 if opts.ggufPath != "" { @@ -99,14 +119,20 @@ Packaging behavior: } if sourcesProvided == 0 { + if opts.modelfile != "" { + return fmt.Errorf( + "Modelfile %q specifies no model source; add a FROM, GGUF, SAFETENSORS_DIR, or DDUF instruction", + opts.modelfile, + ) + } return fmt.Errorf( - "One of --gguf, --safetensors-dir, --dduf, or --from is required.\n\n" + + "One of --gguf, --safetensors-dir, --dduf, --from, or --file is required.\n\n" + "See 'docker model package --help' for more information", ) } if sourcesProvided > 1 { return fmt.Errorf( - "Cannot specify more than one of --gguf, --safetensors-dir, --dduf, or --from. Please use only one source.\n\n" + + "Cannot specify more than one model source (--gguf, --safetensors-dir, --dduf, --from, or a source from --file).\n\n" + "See 'docker model package --help' for more information", ) } @@ -210,17 +236,20 @@ Packaging behavior: 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)") + c.Flags().StringVarP(&opts.modelfile, "file", "f", "", "path to a Modelfile") return c } type packageOptions struct { chatTemplatePath string contextSize uint64 + contextSizeSet bool // context-size provided via Modelfile ggufPath string safetensorsDir string ddufPath string fromModel string licensePaths []string + modelfile string mmprojPath string push bool tag string @@ -378,13 +407,14 @@ func packageModel(ctx context.Context, cmd *cobra.Command, client *desktop.Clien // Use daemon-side repackaging for simple config-only changes (no new // layers). Disabled for CNCF format because the daemon produces // Docker-format artifacts. + contextSizeChanged := cmd.Flags().Changed("context-size") || opts.contextSizeSet canUseDaemonRepackage := opts.fromModel != "" && !opts.push && opts.format != "cncf" && len(opts.licensePaths) == 0 && opts.chatTemplatePath == "" && opts.mmprojPath == "" && - cmd.Flags().Changed("context-size") + contextSizeChanged if canUseDaemonRepackage { cmd.PrintErrf("Reading model from daemon: %q\n", opts.fromModel) @@ -440,7 +470,7 @@ func packageModel(ctx context.Context, cmd *cobra.Command, client *desktop.Clien distClient := initResult.distClient // Set context size - if cmd.Flags().Changed("context-size") { + if contextSizeChanged { cmd.PrintErrf("Setting context size %d\n", opts.contextSize) pkg, err = pkg.WithContextSize(int32(opts.contextSize)) if err != nil { diff --git a/cmd/cli/docs/reference/docker_model_package.yaml b/cmd/cli/docs/reference/docker_model_package.yaml index 7bc696c5b..c8fb2a897 100644 --- a/cmd/cli/docs/reference/docker_model_package.yaml +++ b/cmd/cli/docs/reference/docker_model_package.yaml @@ -8,6 +8,7 @@ long: |- --safetensors-dir A directory containing .safetensors and configuration files --dduf A .dduf (Diffusers Unified Format) archive --from An existing packaged model reference + --file A Modelfile describing the model and its assets By default, the packaged artifact is loaded into the local Model Runner content store. Use --push to publish the model to a registry instead. @@ -39,7 +40,22 @@ long: |- Multimodal models Use --mmproj to include a multimodal projector file. -usage: docker model package (--gguf | --safetensors-dir | --dduf | --from ) [--license ...] [--mmproj ] [--context-size ] [--push] MODEL + + Modelfile + --file accepts a path to a Modelfile. Supported instructions: + + FROM existing model reference + GGUF GGUF file + SAFETENSORS_DIR safetensors directory (alias: SAFETENSORS-DIR) + DDUF DDUF archive + LICENSE license file; may appear multiple times + CHAT_TEMPLATE chat template file (alias: CHAT-TEMPLATE) + MMPROJ multimodal projector (alias: MM-PROJ) + CONTEXT context size in tokens (aliases: CTX, CONTEXT-SIZE) + + Paths may be relative (resolved from the Modelfile's directory) or absolute. + CLI flags take precedence over Modelfile values. +usage: docker model package (--gguf | --safetensors-dir | --dduf | --from | --file ) [--license ...] [--mmproj ] [--context-size ] [--push] MODEL pname: docker model plink: docker_model.yaml options: @@ -71,6 +87,16 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: file + shorthand: f + value_type: string + description: path to a Modelfile + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: format value_type: string default_value: docker diff --git a/cmd/cli/docs/reference/model_package.md b/cmd/cli/docs/reference/model_package.md index 571b77c1f..cc95099f1 100644 --- a/cmd/cli/docs/reference/model_package.md +++ b/cmd/cli/docs/reference/model_package.md @@ -8,6 +8,7 @@ The model source must be one of: --safetensors-dir A directory containing .safetensors and configuration files --dduf A .dduf (Diffusers Unified Format) archive --from An existing packaged model reference + --file A Modelfile describing the model and its assets By default, the packaged artifact is loaded into the local Model Runner content store. Use --push to publish the model to a registry instead. @@ -40,6 +41,21 @@ Packaging behavior: Multimodal models Use --mmproj to include a multimodal projector file. + Modelfile + --file accepts a path to a Modelfile. Supported instructions: + + FROM existing model reference + GGUF GGUF file + SAFETENSORS_DIR safetensors directory (alias: SAFETENSORS-DIR) + DDUF DDUF archive + LICENSE license file; may appear multiple times + CHAT_TEMPLATE chat template file (alias: CHAT-TEMPLATE) + MMPROJ multimodal projector (alias: MM-PROJ) + CONTEXT context size in tokens (aliases: CTX, CONTEXT-SIZE) + + Paths may be relative (resolved from the Modelfile's directory) or absolute. + CLI flags take precedence over Modelfile values. + ### Options | Name | Type | Default | Description | @@ -47,6 +63,7 @@ Packaging behavior: | `--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) | +| `-f`, `--file` | `string` | | path to a Modelfile | | `--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 |