Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions compat/detect.go
Original file line number Diff line number Diff line change
@@ -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
}
}
63 changes: 63 additions & 0 deletions compat/detect_test.go
Original file line number Diff line number Diff line change
@@ -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},

// 未知格式
Comment on lines +26 to +36
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Comments are written in Chinese instead of English. The codebase should maintain consistency with English documentation and comments for broader accessibility. Please translate these comments to English.

Suggested change
// 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},
// 未知格式
// ModelPack format
{"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 format
{"application/vnd.docker.ai.model.config.v0.1+json", FormatDocker},
{"application/vnd.docker.ai.gguf.v3", FormatDocker},
{"application/vnd.docker.ai.license", FormatDocker},
// Unknown format

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +36
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Comments are written in Chinese instead of English. The codebase should maintain consistency with English documentation and comments for broader accessibility. Please translate these comments to English.

Suggested change
// 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},
// 未知格式
// ModelPack format
{"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 format
{"application/vnd.docker.ai.model.config.v0.1+json", FormatDocker},
{"application/vnd.docker.ai.gguf.v3", FormatDocker},
{"application/vnd.docker.ai.license", FormatDocker},
// Unknown format

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Comments are written in Chinese instead of English. The codebase should maintain consistency with English documentation and comments for broader accessibility. Please translate these comments to English.

Suggested change
// 未知格式
// Unknown format

Copilot uses AI. Check for mistakes.
{"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)
}
}
}
Comment on lines +21 to +46
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This test function can be improved for better maintainability and readability:

  1. Use English comments: The comments are in Chinese, while the rest of the codebase is in English. It's best to maintain consistency for all developers.
  2. Use subtests: For table-driven tests, using t.Run provides clearer output when a test case fails, as it names the specific test that failed. This makes debugging easier.

Here is a suggested refactoring that addresses both points:

func TestDetectFormat(t *testing.T) {
	tests := []struct {
		name      string
		mediaType string
		want      Format
	}{
		// ModelPack formats
		{"ModelPack config", "application/vnd.cncf.model.config.v1+json", FormatModelPack},
		{"ModelPack manifest", "application/vnd.cncf.model.manifest.v1+json", FormatModelPack},
		{"ModelPack weights", "application/vnd.cncf.model.weight.v1.raw", FormatModelPack},

		// Docker formats
		{"Docker config", "application/vnd.docker.ai.model.config.v0.1+json", FormatDocker},
		{"Docker GGUF", "application/vnd.docker.ai.gguf.v3", FormatDocker},
		{"Docker license", "application/vnd.docker.ai.license", FormatDocker},

		// Unknown formats
		{"Generic JSON", "application/json", FormatUnknown},
		{"Octet stream", "application/octet-stream", FormatUnknown},
		{"Empty media type", "", FormatUnknown},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := DetectFormat(tt.mediaType)
			if got != tt.want {
				t.Errorf("DetectFormat(%q) = %v, want %v", 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)
}
}
}
81 changes: 81 additions & 0 deletions compat/docker/v0/config.go
Original file line number Diff line number Diff line change
@@ -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 <alg>:<hash>
DiffID string `json:"diffID"`

// The media type indicating how to interpret the file
Type string `json:"type"`
}
178 changes: 178 additions & 0 deletions compat/docker/v0/config_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading
Loading