From a7c4ad570e76adc0b4364674c8bf18bf1a9d2755 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:42:28 +0000 Subject: [PATCH] feat(compat): add Docker model-spec format conversion Add compat package for bidirectional conversion between ModelPack and Docker model-spec formats. This enables interoperability with docker/model-runner as requested in issue #151. Changes: - Add compat/docker/v0 package with Docker config types and media types - Implement FromModelPack() and ToModelPack() conversion functions - Add compat.DetectFormat() for media type detection - Add docs/compatibility.md with usage examples - Update README.md with compatibility section Closes #151 Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- README.md | 8 + compat/detect.go | 53 +++++ compat/detect_test.go | 63 ++++++ compat/docker/v0/config.go | 81 +++++++ compat/docker/v0/config_test.go | 178 +++++++++++++++ compat/docker/v0/convert.go | 244 +++++++++++++++++++++ compat/docker/v0/convert_test.go | 360 +++++++++++++++++++++++++++++++ docs/compatibility.md | 118 ++++++++++ 8 files changed, 1105 insertions(+) create mode 100644 compat/detect.go create mode 100644 compat/detect_test.go create mode 100644 compat/docker/v0/config.go create mode 100644 compat/docker/v0/config_test.go create mode 100644 compat/docker/v0/convert.go create mode 100644 compat/docker/v0/convert_test.go create mode 100644 docs/compatibility.md diff --git a/README.md b/README.md index e5fa6d7..221e8b9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,14 @@ This specification provides a compatible way to package and distribute models ba For details, please see [the specification](docs/spec.md). +## Compatibility + +The `compat` package provides tools for converting between ModelPack and other model packaging formats. Currently supported: + +- [Docker Model Spec](https://github.com/docker/model-spec) - bidirectional conversion + +For details, please see [the compatibility guide](docs/compatibility.md). + ## Getting Started Please see [the getting started guide](docs/getting-started.md) for a quick introduction to the specification and how to use it. diff --git a/compat/detect.go b/compat/detect.go new file mode 100644 index 0000000..4d523c4 --- /dev/null +++ b/compat/detect.go @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +// Package compat provides compatibility utilities between ModelPack and other formats. +package compat + +import "strings" + +// Format represents the format type of a model artifact. +type Format int + +const ( + FormatUnknown Format = iota + FormatModelPack + FormatDocker +) + +// String returns the format name. +func (f Format) String() string { + switch f { + case FormatModelPack: + return "modelpack" + case FormatDocker: + return "docker" + default: + return "unknown" + } +} + +// DetectFormat determines the artifact format based on its media type. +func DetectFormat(mediaType string) Format { + switch { + case strings.HasPrefix(mediaType, "application/vnd.cncf.model."): + return FormatModelPack + case strings.HasPrefix(mediaType, "application/vnd.docker.ai."): + return FormatDocker + default: + return FormatUnknown + } +} diff --git a/compat/detect_test.go b/compat/detect_test.go new file mode 100644 index 0000000..ef7fd0c --- /dev/null +++ b/compat/detect_test.go @@ -0,0 +1,63 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +package compat + +import "testing" + +func TestDetectFormat(t *testing.T) { + for i, tt := range []struct { + mediaType string + want Format + }{ + // ModelPack 格式 + {"application/vnd.cncf.model.config.v1+json", FormatModelPack}, + {"application/vnd.cncf.model.manifest.v1+json", FormatModelPack}, + {"application/vnd.cncf.model.weight.v1.raw", FormatModelPack}, + + // Docker 格式 + {"application/vnd.docker.ai.model.config.v0.1+json", FormatDocker}, + {"application/vnd.docker.ai.gguf.v3", FormatDocker}, + {"application/vnd.docker.ai.license", FormatDocker}, + + // 未知格式 + {"application/json", FormatUnknown}, + {"application/octet-stream", FormatUnknown}, + {"", FormatUnknown}, + } { + got := DetectFormat(tt.mediaType) + if got != tt.want { + t.Errorf("test %d: DetectFormat(%q) = %v, want %v", i, tt.mediaType, got, tt.want) + } + } +} + +func TestFormatString(t *testing.T) { + tests := []struct { + f Format + want string + }{ + {FormatModelPack, "modelpack"}, + {FormatDocker, "docker"}, + {FormatUnknown, "unknown"}, + } + + for _, tt := range tests { + if got := tt.f.String(); got != tt.want { + t.Errorf("Format(%d).String() = %q, want %q", tt.f, got, tt.want) + } + } +} diff --git a/compat/docker/v0/config.go b/compat/docker/v0/config.go new file mode 100644 index 0000000..88b928c --- /dev/null +++ b/compat/docker/v0/config.go @@ -0,0 +1,81 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +// Package v0 provides type definitions for Docker model-spec v0.1 format. +// See: https://github.com/docker/model-spec/blob/main/config.md +package v0 + +// FormatGGUF is the primary format supported by Docker model-spec. +const FormatGGUF = "gguf" + +// Media types for Docker AI model artifacts. +// See: https://github.com/docker/model-spec/blob/main/spec.md +const ( + // MediaTypeConfig is the config media type. + MediaTypeConfig = "application/vnd.docker.ai.model.config.v0.1+json" + + // MediaTypeGGUF is the GGUF model file media type. + MediaTypeGGUF = "application/vnd.docker.ai.gguf.v3" + + // MediaTypeLoRA is the LoRA adapter media type. + MediaTypeLoRA = "application/vnd.docker.ai.gguf.v3.lora" + + // MediaTypeMMProj is the multimodal projector media type. + MediaTypeMMProj = "application/vnd.docker.ai.gguf.v3.mmproj" + + // MediaTypeLicense is the license file media type. + MediaTypeLicense = "application/vnd.docker.ai.license" + + // MediaTypeChatTemplate is the Jinja chat template media type. + MediaTypeChatTemplate = "application/vnd.docker.ai.chat.template.jinja" +) + +// Config is the root structure for Docker AI model config. +// Media type: application/vnd.docker.ai.model.config.v0.1+json +type Config struct { + Descriptor *Descriptor `json:"descriptor,omitempty"` + ModelConfig ModelConfig `json:"config"` + Files []File `json:"files"` +} + +// Descriptor contains provenance information about the artifact. +type Descriptor struct { + CreatedAt string `json:"createdAt,omitempty"` +} + +// ModelConfig contains technical metadata about the model. +type ModelConfig struct { + // The packaging format (e.g., "gguf") + Format string `json:"format"` + + // The packaging format version + FormatVersion string `json:"format_version,omitempty"` + + // The total size of the model in bytes + Size string `json:"size"` + + // Format-specific metadata (for GGUF models) + GGUF map[string]any `json:"gguf,omitempty"` +} + +// File describes a single file that makes up the model. +type File struct { + // The file digest as : + DiffID string `json:"diffID"` + + // The media type indicating how to interpret the file + Type string `json:"type"` +} diff --git a/compat/docker/v0/config_test.go b/compat/docker/v0/config_test.go new file mode 100644 index 0000000..51f1581 --- /dev/null +++ b/compat/docker/v0/config_test.go @@ -0,0 +1,178 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +package v0 + +import ( + "encoding/json" + "testing" +) + +func TestConfigUnmarshal(t *testing.T) { + for i, tt := range []struct { + name string + input string + wantErr bool + }{ + // minimal valid config + { + name: "minimal config", + input: `{ + "config": {"format": "gguf", "size": "1000"}, + "files": [] + }`, + wantErr: false, + }, + // full config matching Docker model-spec example + { + name: "full config", + input: `{ + "descriptor": {"createdAt": "2025-01-01T00:00:00Z"}, + "config": { + "format": "gguf", + "format_version": "3", + "gguf": { + "architecture": "llama", + "parameter_count": "1.10 B", + "quantization": "Q4_0" + }, + "size": "635992801" + }, + "files": [ + {"diffID": "sha256:abc123", "type": "application/vnd.docker.ai.gguf.v3"}, + {"diffID": "sha256:def456", "type": "application/vnd.docker.ai.license"} + ] + }`, + wantErr: false, + }, + // empty json + { + name: "empty json", + input: `{}`, + wantErr: false, + }, + // invalid json + { + name: "invalid json", + input: `{not valid}`, + wantErr: true, + }, + } { + var cfg Config + err := json.Unmarshal([]byte(tt.input), &cfg) + + if (err != nil) != tt.wantErr { + t.Errorf("test %d (%s): wantErr=%v, got err=%v", i, tt.name, tt.wantErr, err) + } + } +} + +func TestConfigMarshal(t *testing.T) { + original := Config{ + Descriptor: &Descriptor{ + CreatedAt: "2025-01-01T00:00:00Z", + }, + ModelConfig: ModelConfig{ + Format: FormatGGUF, + FormatVersion: "3", + Size: "635992801", + GGUF: map[string]any{ + "architecture": "llama", + "parameter_count": "1.10 B", + "quantization": "Q4_0", + }, + }, + Files: []File{ + {DiffID: "sha256:abc123", Type: MediaTypeGGUF}, + {DiffID: "sha256:def456", Type: MediaTypeLicense}, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ModelConfig.Format != original.ModelConfig.Format { + t.Errorf("format mismatch: got %q, want %q", decoded.ModelConfig.Format, original.ModelConfig.Format) + } + if decoded.ModelConfig.Size != original.ModelConfig.Size { + t.Errorf("size mismatch: got %q, want %q", decoded.ModelConfig.Size, original.ModelConfig.Size) + } + if len(decoded.Files) != len(original.Files) { + t.Errorf("files count mismatch: got %d, want %d", len(decoded.Files), len(original.Files)) + } + if decoded.ModelConfig.GGUF["architecture"] != original.ModelConfig.GGUF["architecture"] { + t.Error("gguf.architecture mismatch") + } +} + +func TestConfigMatchesDockerSpec(t *testing.T) { + // This test verifies our struct produces JSON matching Docker model-spec example + cfg := Config{ + Descriptor: &Descriptor{ + CreatedAt: "2025-01-01T00:00:00Z", + }, + ModelConfig: ModelConfig{ + Format: FormatGGUF, + FormatVersion: "3", + Size: "635992801", + GGUF: map[string]any{ + "architecture": "llama", + "parameter_count": "1.10 B", + "quantization": "Q4_0", + }, + }, + Files: []File{ + { + DiffID: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + Type: MediaTypeGGUF, + }, + { + DiffID: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + Type: MediaTypeLicense, + }, + { + DiffID: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + Type: MediaTypeLoRA, + }, + }, + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // Verify it can be unmarshaled back + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // Verify key structure matches Docker spec + if decoded.ModelConfig.GGUF == nil { + t.Error("gguf should be inside config, not at top level") + } + if decoded.ModelConfig.GGUF["parameter_count"] != "1.10 B" { + t.Errorf("parameter_count format should be '1.10 B', got %v", decoded.ModelConfig.GGUF["parameter_count"]) + } +} diff --git a/compat/docker/v0/convert.go b/compat/docker/v0/convert.go new file mode 100644 index 0000000..a4334db --- /dev/null +++ b/compat/docker/v0/convert.go @@ -0,0 +1,244 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +package v0 + +import ( + "strconv" + "strings" + "time" + + v1 "github.com/modelpack/model-spec/specs-go/v1" + "github.com/opencontainers/go-digest" +) + +// FromModelPack converts a ModelPack model to Docker format. +// Note: The Size field cannot be derived from ModelPack and will be set to "0". +func FromModelPack(m v1.Model) (*Config, error) { + cfg := &Config{ + ModelConfig: ModelConfig{ + Format: m.Config.Format, + Size: "0", + }, + Files: make([]File, 0, len(m.ModelFS.DiffIDs)), + } + + if m.Descriptor.CreatedAt != nil { + cfg.Descriptor = &Descriptor{ + CreatedAt: m.Descriptor.CreatedAt.Format(time.RFC3339), + } + } + + for _, diffID := range m.ModelFS.DiffIDs { + cfg.Files = append(cfg.Files, File{ + DiffID: string(diffID), + Type: mediaTypeForFormat(m.Config.Format), + }) + } + + if m.Config.ParamSize != "" { + cfg.ModelConfig.GGUF = map[string]any{ + "parameter_count": formatParamSizeHuman(m.Config.ParamSize), + } + } + + if m.Config.Architecture != "" { + if cfg.ModelConfig.GGUF == nil { + cfg.ModelConfig.GGUF = make(map[string]any) + } + cfg.ModelConfig.GGUF["architecture"] = m.Config.Architecture + } + + if m.Config.Quantization != "" { + if cfg.ModelConfig.GGUF == nil { + cfg.ModelConfig.GGUF = make(map[string]any) + } + cfg.ModelConfig.GGUF["quantization"] = m.Config.Quantization + } + + return cfg, nil +} + +// ToModelPack converts a Docker format config to ModelPack format. +func ToModelPack(cfg Config) (*v1.Model, error) { + m := &v1.Model{ + Config: v1.ModelConfig{ + Format: cfg.ModelConfig.Format, + }, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: make([]digest.Digest, 0, len(cfg.Files)), + }, + } + + if cfg.Descriptor != nil && cfg.Descriptor.CreatedAt != "" { + t, err := time.Parse(time.RFC3339, cfg.Descriptor.CreatedAt) + if err == nil { + m.Descriptor.CreatedAt = &t + } + } + + for _, f := range cfg.Files { + m.ModelFS.DiffIDs = append(m.ModelFS.DiffIDs, digest.Digest(f.DiffID)) + } + + if cfg.ModelConfig.GGUF != nil { + if paramCount, ok := cfg.ModelConfig.GGUF["parameter_count"]; ok { + if s, ok := paramCount.(string); ok { + m.Config.ParamSize = parseParamSizeHuman(s) + } + } + if arch, ok := cfg.ModelConfig.GGUF["architecture"]; ok { + if s, ok := arch.(string); ok { + m.Config.Architecture = s + } + } + if quant, ok := cfg.ModelConfig.GGUF["quantization"]; ok { + if s, ok := quant.(string); ok { + m.Config.Quantization = s + } + } + } + + return m, nil +} + +// mediaTypeForFormat returns the Docker media type for a given format. +func mediaTypeForFormat(format string) string { + switch strings.ToLower(format) { + case FormatGGUF: + return MediaTypeGGUF + default: + return "application/octet-stream" + } +} + +// formatParamSizeHuman converts "8b" to Docker format "8 B". +func formatParamSizeHuman(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + + var numPart string + var unitPart string + lower := strings.ToLower(s) + for i, c := range lower { + if (c >= '0' && c <= '9') || c == '.' { + numPart = s[:i+1] + } else { + unitPart = lower[i:] + break + } + } + + if numPart == "" { + return s + } + + switch unitPart { + case "t": + return numPart + " T" + case "b": + return numPart + " B" + case "m": + return numPart + " M" + case "k": + return numPart + " K" + default: + return s + } +} + +// parseParamSizeHuman converts Docker format "8 B" to ModelPack format "8b". +func parseParamSizeHuman(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + + parts := strings.Fields(s) + if len(parts) != 2 { + return strings.ToLower(strings.ReplaceAll(s, " ", "")) + } + + num := parts[0] + unit := strings.ToLower(parts[1]) + + return num + unit +} + +// parseParamSize parses parameter size string, e.g., "8b" -> 8000000000. +func parseParamSize(s string) int64 { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0 + } + + var numPart string + var unitPart string + for i, c := range s { + if (c >= '0' && c <= '9') || c == '.' { + numPart = s[:i+1] + } else { + unitPart = s[i:] + break + } + } + + if numPart == "" { + numPart = s + } + + num, err := strconv.ParseFloat(numPart, 64) + if err != nil { + return 0 + } + + var multiplier int64 = 1 + switch strings.ToLower(unitPart) { + case "t": + multiplier = 1_000_000_000_000 + case "b": + multiplier = 1_000_000_000 + case "m": + multiplier = 1_000_000 + case "k": + multiplier = 1_000 + } + + return int64(num * float64(multiplier)) +} + +// formatParamSize converts a number to human-readable format, e.g., 8000000000 -> "8b". +func formatParamSize(n int64) string { + if n <= 0 { + return "" + } + + switch { + case n >= 1_000_000_000_000: + return strconv.FormatFloat(float64(n)/1_000_000_000_000, 'f', -1, 64) + "t" + case n >= 1_000_000_000: + return strconv.FormatFloat(float64(n)/1_000_000_000, 'f', -1, 64) + "b" + case n >= 1_000_000: + return strconv.FormatFloat(float64(n)/1_000_000, 'f', -1, 64) + "m" + case n >= 1_000: + return strconv.FormatFloat(float64(n)/1_000, 'f', -1, 64) + "k" + default: + return strconv.FormatInt(n, 10) + } +} diff --git a/compat/docker/v0/convert_test.go b/compat/docker/v0/convert_test.go new file mode 100644 index 0000000..388e703 --- /dev/null +++ b/compat/docker/v0/convert_test.go @@ -0,0 +1,360 @@ +/* + * Copyright 2025 The CNCF ModelPack 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. + */ + +package v0 + +import ( + "encoding/json" + "testing" + "time" + + v1 "github.com/modelpack/model-spec/specs-go/v1" + "github.com/opencontainers/go-digest" +) + +func TestFromModelPack(t *testing.T) { + now := time.Now() + + for i, tt := range []struct { + name string + input v1.Model + wantErr bool + checkFn func(t *testing.T, got *Config) + }{ + // basic conversion with only required fields + { + name: "basic model", + input: v1.Model{ + Descriptor: v1.ModelDescriptor{}, + Config: v1.ModelConfig{Format: FormatGGUF}, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:abc123"}, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *Config) { + if got.ModelConfig.Format != FormatGGUF { + t.Errorf("format: got %q, want %q", got.ModelConfig.Format, FormatGGUF) + } + if len(got.Files) != 1 { + t.Errorf("files count: got %d, want 1", len(got.Files)) + } + if got.ModelConfig.Size != "0" { + t.Errorf("size: got %q, want %q", got.ModelConfig.Size, "0") + } + }, + }, + // conversion with timestamp + { + name: "with timestamp", + input: v1.Model{ + Descriptor: v1.ModelDescriptor{ + CreatedAt: &now, + }, + Config: v1.ModelConfig{Format: FormatGGUF}, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:def456"}, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *Config) { + if got.Descriptor == nil { + t.Fatal("descriptor should not be nil") + } + if got.Descriptor.CreatedAt == "" { + t.Error("createdAt should not be empty") + } + }, + }, + // conversion with multiple layers + { + name: "multiple layers", + input: v1.Model{ + Config: v1.ModelConfig{Format: FormatGGUF}, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{ + "sha256:layer1", + "sha256:layer2", + "sha256:layer3", + }, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *Config) { + if len(got.Files) != 3 { + t.Errorf("files count: got %d, want 3", len(got.Files)) + } + }, + }, + // conversion with paramSize + { + name: "with paramSize", + input: v1.Model{ + Config: v1.ModelConfig{ + Format: FormatGGUF, + ParamSize: "8b", + }, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:abc123"}, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *Config) { + if got.ModelConfig.GGUF == nil { + t.Fatal("gguf should not be nil") + } + paramCount, ok := got.ModelConfig.GGUF["parameter_count"] + if !ok { + t.Fatal("parameter_count should exist in gguf") + } + if paramCount != "8 B" { + t.Errorf("parameter_count: got %v, want %q", paramCount, "8 B") + } + }, + }, + // conversion with architecture and quantization + { + name: "with architecture and quantization", + input: v1.Model{ + Config: v1.ModelConfig{ + Format: FormatGGUF, + Architecture: "llama", + Quantization: "Q4_0", + }, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:abc123"}, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *Config) { + if got.ModelConfig.GGUF == nil { + t.Fatal("gguf should not be nil") + } + if got.ModelConfig.GGUF["architecture"] != "llama" { + t.Errorf("architecture: got %v, want %q", got.ModelConfig.GGUF["architecture"], "llama") + } + if got.ModelConfig.GGUF["quantization"] != "Q4_0" { + t.Errorf("quantization: got %v, want %q", got.ModelConfig.GGUF["quantization"], "Q4_0") + } + }, + }, + } { + got, err := FromModelPack(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("test %d (%s): wantErr=%v, got err=%v", i, tt.name, tt.wantErr, err) + continue + } + + if !tt.wantErr && tt.checkFn != nil { + tt.checkFn(t, got) + } + } +} + +func TestToModelPack(t *testing.T) { + for i, tt := range []struct { + name string + input Config + wantErr bool + checkFn func(t *testing.T, got *v1.Model) + }{ + // basic conversion + { + name: "basic config", + input: Config{ + ModelConfig: ModelConfig{Format: FormatGGUF, Size: "1000"}, + Files: []File{ + {DiffID: "sha256:abc123", Type: "application/vnd.docker.ai.gguf.v3"}, + }, + }, + wantErr: false, + checkFn: func(t *testing.T, got *v1.Model) { + if got.Config.Format != FormatGGUF { + t.Errorf("format: got %q, want %q", got.Config.Format, FormatGGUF) + } + if len(got.ModelFS.DiffIDs) != 1 { + t.Errorf("diffIDs count: got %d, want 1", len(got.ModelFS.DiffIDs)) + } + }, + }, + // conversion with descriptor + { + name: "with descriptor", + input: Config{ + Descriptor: &Descriptor{CreatedAt: "2025-01-01T00:00:00Z"}, + ModelConfig: ModelConfig{Format: FormatGGUF, Size: "1000"}, + Files: []File{}, + }, + wantErr: false, + checkFn: func(t *testing.T, got *v1.Model) { + if got.Descriptor.CreatedAt == nil { + t.Error("createdAt should not be nil") + } + }, + }, + // conversion with gguf metadata + { + name: "with gguf metadata", + input: Config{ + ModelConfig: ModelConfig{ + Format: FormatGGUF, + Size: "1000", + GGUF: map[string]any{ + "parameter_count": "8 B", + "architecture": "llama", + "quantization": "Q4_0", + }, + }, + Files: []File{}, + }, + wantErr: false, + checkFn: func(t *testing.T, got *v1.Model) { + if got.Config.ParamSize != "8b" { + t.Errorf("paramSize: got %q, want %q", got.Config.ParamSize, "8b") + } + if got.Config.Architecture != "llama" { + t.Errorf("architecture: got %q, want %q", got.Config.Architecture, "llama") + } + if got.Config.Quantization != "Q4_0" { + t.Errorf("quantization: got %q, want %q", got.Config.Quantization, "Q4_0") + } + }, + }, + } { + got, err := ToModelPack(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("test %d (%s): wantErr=%v, got err=%v", i, tt.name, tt.wantErr, err) + continue + } + + if !tt.wantErr && tt.checkFn != nil { + tt.checkFn(t, got) + } + } +} + +func TestParseParamSize(t *testing.T) { + for i, tt := range []struct { + input string + want int64 + }{ + {"8b", 8_000_000_000}, + {"8B", 8_000_000_000}, + {"70b", 70_000_000_000}, + {"1.5b", 1_500_000_000}, + {"7m", 7_000_000}, + {"100k", 100_000}, + {"1t", 1_000_000_000_000}, + {"", 0}, + {"invalid", 0}, + } { + got := parseParamSize(tt.input) + if got != tt.want { + t.Errorf("test %d: parseParamSize(%q) = %d, want %d", i, tt.input, got, tt.want) + } + } +} + +func TestFormatParamSizeHuman(t *testing.T) { + for i, tt := range []struct { + input string + want string + }{ + {"8b", "8 B"}, + {"8B", "8 B"}, + {"70b", "70 B"}, + {"1.5b", "1.5 B"}, + {"7m", "7 M"}, + {"100k", "100 K"}, + {"1t", "1 T"}, + {"", ""}, + {"invalid", "invalid"}, + } { + got := formatParamSizeHuman(tt.input) + if got != tt.want { + t.Errorf("test %d: formatParamSizeHuman(%q) = %q, want %q", i, tt.input, got, tt.want) + } + } +} + +func TestParseParamSizeHuman(t *testing.T) { + for i, tt := range []struct { + input string + want string + }{ + {"8 B", "8b"}, + {"70 B", "70b"}, + {"1.5 B", "1.5b"}, + {"7 M", "7m"}, + {"100 K", "100k"}, + {"1 T", "1t"}, + {"", ""}, + {"8B", "8b"}, + } { + got := parseParamSizeHuman(tt.input) + if got != tt.want { + t.Errorf("test %d: parseParamSizeHuman(%q) = %q, want %q", i, tt.input, got, tt.want) + } + } +} + +func TestJSONSerialization(t *testing.T) { + cfg := Config{ + Descriptor: &Descriptor{CreatedAt: "2025-01-01T00:00:00Z"}, + ModelConfig: ModelConfig{ + Format: FormatGGUF, + Size: "635992801", + GGUF: map[string]any{ + "architecture": "llama", + "parameter_count": "1.10 B", + "quantization": "Q4_0", + }, + }, + Files: []File{ + { + DiffID: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + Type: "application/vnd.docker.ai.gguf.v3", + }, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var unmarshaled Config + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if unmarshaled.ModelConfig.Format != cfg.ModelConfig.Format { + t.Errorf("format mismatch: got %q, want %q", unmarshaled.ModelConfig.Format, cfg.ModelConfig.Format) + } + if unmarshaled.ModelConfig.Size != cfg.ModelConfig.Size { + t.Errorf("size mismatch: got %q, want %q", unmarshaled.ModelConfig.Size, cfg.ModelConfig.Size) + } + if unmarshaled.ModelConfig.GGUF["architecture"] != cfg.ModelConfig.GGUF["architecture"] { + t.Errorf("architecture mismatch") + } +} diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..07d7763 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,118 @@ +# Compatibility + +This document describes the compatibility support between ModelPack and other model packaging formats. + +## Docker Model Spec + +The `compat` package provides conversion utilities between CNCF ModelPack and [Docker Model Spec](https://github.com/docker/model-spec) formats. + +### Format Detection + +Use `compat.DetectFormat()` to identify the format of a model artifact by its media type: + +```go +import "github.com/modelpack/model-spec/compat" + +format := compat.DetectFormat("application/vnd.cncf.model.config.v1+json") +// Returns: compat.FormatModelPack + +format = compat.DetectFormat("application/vnd.docker.ai.model.config.v0.1+json") +// Returns: compat.FormatDocker +``` + +### Media Types + +The `dockerv0` package provides constants for Docker model-spec media types: + +```go +import dockerv0 "github.com/modelpack/model-spec/compat/docker/v0" + +dockerv0.MediaTypeConfig // application/vnd.docker.ai.model.config.v0.1+json +dockerv0.MediaTypeGGUF // application/vnd.docker.ai.gguf.v3 +dockerv0.MediaTypeLoRA // application/vnd.docker.ai.gguf.v3.lora +dockerv0.MediaTypeMMProj // application/vnd.docker.ai.gguf.v3.mmproj +dockerv0.MediaTypeLicense // application/vnd.docker.ai.license +dockerv0.MediaTypeChatTemplate // application/vnd.docker.ai.chat.template.jinja +``` + +### Converting ModelPack to Docker Format + +```go +import ( + v1 "github.com/modelpack/model-spec/specs-go/v1" + dockerv0 "github.com/modelpack/model-spec/compat/docker/v0" +) + +model := v1.Model{ + Config: v1.ModelConfig{ + Format: "gguf", + ParamSize: "8b", + Architecture: "llama", + Quantization: "Q4_0", + }, + ModelFS: v1.ModelFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:abc123"}, + }, +} + +dockerCfg, err := dockerv0.FromModelPack(model) +// dockerCfg.ModelConfig.GGUF["parameter_count"] == "8 B" +// dockerCfg.ModelConfig.GGUF["architecture"] == "llama" +// dockerCfg.ModelConfig.GGUF["quantization"] == "Q4_0" +``` + +### Converting Docker Format to ModelPack + +```go +import dockerv0 "github.com/modelpack/model-spec/compat/docker/v0" + +dockerCfg := dockerv0.Config{ + ModelConfig: dockerv0.ModelConfig{ + Format: "gguf", + Size: "635992801", + GGUF: map[string]any{ + "parameter_count": "8 B", + "architecture": "llama", + "quantization": "Q4_0", + }, + }, + Files: []dockerv0.File{ + {DiffID: "sha256:abc123", Type: dockerv0.MediaTypeGGUF}, + }, +} + +model, err := dockerv0.ToModelPack(dockerCfg) +// model.Config.ParamSize == "8b" +// model.Config.Architecture == "llama" +// model.Config.Quantization == "Q4_0" +``` + +### Field Mapping + +| ModelPack | Docker | Notes | +| --------- | ------ | ----- | +| `descriptor.createdAt` | `descriptor.createdAt` | RFC3339 format | +| `config.format` | `config.format` | Direct mapping | +| `config.paramSize` | `config.gguf.parameter_count` | Format conversion (e.g., "8b" ↔ "8 B") | +| `config.architecture` | `config.gguf.architecture` | Direct mapping | +| `config.quantization` | `config.gguf.quantization` | Direct mapping | +| `modelfs.diffIds` | `files[].diffID` | Structure conversion | + +### Limitations + +**Size field:** The Docker `config.size` field (total model size in bytes) cannot be derived from ModelPack format. When converting ModelPack to Docker, this field is set to "0". Callers should update this field if the actual size is known. + +**Fields lost when converting ModelPack to Docker:** + +- `descriptor.authors` +- `descriptor.name`, `descriptor.version` +- `descriptor.vendor`, `descriptor.licenses` +- `descriptor.family`, `descriptor.title`, `descriptor.description` +- `config.precision` +- `config.capabilities.*` + +**Fields lost when converting Docker to ModelPack:** + +- `config.format_version` +- `config.gguf.*` (except `parameter_count`, `architecture`, `quantization`)