-
Notifications
You must be signed in to change notification settings - Fork 23
feat(compat): add Docker model-spec format conversion #152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } |
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| // 未知格式 | |
| // Unknown format |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test function can be improved for better maintainability and readability:
- 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.
- Use subtests: For table-driven tests, using
t.Runprovides 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)
}
})
}
}| 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"` | ||
| } |
| 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"]) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.