Skip to content

Commit 816dd2a

Browse files
authored
refactor: normalize ModelPack config to Docker format in API responses (#875)
1 parent 69b941e commit 816dd2a

3 files changed

Lines changed: 138 additions & 4 deletions

File tree

pkg/distribution/internal/partial/partial.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,29 @@ func ConfigFile(i WithRawConfigFile) (*types.ConfigFile, error) {
5656
}
5757

5858
// Descriptor returns the types.Descriptor for the model.
59+
// Supports both Docker format (where created is in descriptor.created)
60+
// and CNCF ModelPack format (where created is in descriptor.createdAt).
5961
func Descriptor(i WithRawConfigFile) (types.Descriptor, error) {
60-
cf, err := ConfigFile(i)
62+
raw, err := i.RawConfigFile()
6163
if err != nil {
62-
return types.Descriptor{}, fmt.Errorf("config file: %w", err)
64+
return types.Descriptor{}, fmt.Errorf("get raw config file: %w", err)
65+
}
66+
67+
// ModelPack format: extract createdAt from the ModelPack descriptor.
68+
// Docker's types.Descriptor uses "created" (snake_case) while ModelPack
69+
// uses "createdAt" (camelCase), so we must parse them separately.
70+
if modelpack.IsModelPackConfig(raw) {
71+
var mp modelpack.Model
72+
if err := json.Unmarshal(raw, &mp); err != nil {
73+
return types.Descriptor{}, fmt.Errorf("unmarshal modelpack config: %w", err)
74+
}
75+
return types.Descriptor{Created: mp.Descriptor.CreatedAt}, nil
76+
}
77+
78+
// Docker format
79+
var cf types.ConfigFile
80+
if err := json.Unmarshal(raw, &cf); err != nil {
81+
return types.Descriptor{}, fmt.Errorf("unmarshal config: %w", err)
6382
}
6483
return cf.Descriptor, nil
6584
}

pkg/inference/models/adapter.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,29 @@ package models
33
import (
44
"fmt"
55

6+
"github.com/docker/model-runner/pkg/distribution/modelpack"
67
"github.com/docker/model-runner/pkg/distribution/types"
78
)
89

10+
// normalizeConfig converts a ModelPack config to Docker format types.Config
11+
// so that the API wire format is always consistent. This ensures clients
12+
// don't need to understand both config formats.
13+
func normalizeConfig(cfg types.ModelConfig) types.ModelConfig {
14+
if cfg == nil {
15+
return nil
16+
}
17+
if _, ok := cfg.(*modelpack.Model); ok {
18+
return &types.Config{
19+
Format: cfg.GetFormat(),
20+
Parameters: cfg.GetParameters(),
21+
Quantization: cfg.GetQuantization(),
22+
Architecture: cfg.GetArchitecture(),
23+
Size: cfg.GetSize(),
24+
}
25+
}
26+
return cfg
27+
}
28+
929
func ToModel(m types.Model) (*Model, error) {
1030
desc, err := m.Descriptor()
1131
if err != nil {
@@ -31,7 +51,7 @@ func ToModel(m types.Model) (*Model, error) {
3151
ID: id,
3252
Tags: m.Tags(),
3353
Created: created,
34-
Config: cfg,
54+
Config: normalizeConfig(cfg),
3555
}, nil
3656
}
3757

@@ -62,6 +82,6 @@ func ToModelFromArtifact(artifact types.ModelArtifact) (*Model, error) {
6282
ID: id,
6383
Tags: nil, // Remote models don't have local tags
6484
Created: created,
65-
Config: cfg,
85+
Config: normalizeConfig(cfg),
6686
}, nil
6787
}

pkg/inference/models/api_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"testing"
66

7+
"github.com/docker/model-runner/pkg/distribution/modelpack"
78
"github.com/docker/model-runner/pkg/distribution/types"
89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
@@ -478,6 +479,100 @@ func TestToOpenAIList(t *testing.T) {
478479
assert.Nil(t, result.Data[1].DMR)
479480
}
480481

482+
func TestNormalizeConfigModelPack(t *testing.T) {
483+
// Test that normalizeConfig converts ModelPack config to Docker format.
484+
// This simulates what happens in ToModel() when the server normalizes
485+
// CNCF configs before serializing the API response.
486+
mp := &modelpack.Model{
487+
Descriptor: modelpack.ModelDescriptor{
488+
Family: "qwen3",
489+
},
490+
Config: modelpack.ModelConfig{
491+
Architecture: "qwen3",
492+
Format: "safetensors",
493+
ParamSize: "0.6B",
494+
Quantization: "F16",
495+
},
496+
ModelFS: modelpack.ModelFS{
497+
Type: "layers",
498+
},
499+
}
500+
501+
normalized := normalizeConfig(mp)
502+
require.NotNil(t, normalized)
503+
504+
// Should be converted to *types.Config
505+
dockerCfg, ok := normalized.(*types.Config)
506+
require.True(t, ok, "Normalized config should be *types.Config")
507+
assert.Equal(t, types.FormatSafetensors, dockerCfg.Format)
508+
assert.Equal(t, "0.6B", dockerCfg.Parameters)
509+
assert.Equal(t, "F16", dockerCfg.Quantization)
510+
assert.Equal(t, "qwen3", dockerCfg.Architecture)
511+
assert.Equal(t, "0.6B", dockerCfg.Size)
512+
}
513+
514+
func TestNormalizeConfigDocker(t *testing.T) {
515+
// Docker format configs should pass through unchanged.
516+
dockerCfg := &types.Config{
517+
Format: "gguf",
518+
Parameters: "7B",
519+
Quantization: "Q4_K_M",
520+
Architecture: "llama",
521+
Size: "7B",
522+
}
523+
524+
normalized := normalizeConfig(dockerCfg)
525+
assert.Equal(t, dockerCfg, normalized, "Docker config should pass through unchanged")
526+
}
527+
528+
func TestNormalizeConfigNil(t *testing.T) {
529+
assert.Nil(t, normalizeConfig(nil), "nil config should return nil")
530+
}
531+
532+
func TestToModelWithModelPackConfig(t *testing.T) {
533+
// Test that ToModel properly normalizes a ModelPack config and
534+
// the resulting JSON is always in Docker format.
535+
mp := &modelpack.Model{
536+
Config: modelpack.ModelConfig{
537+
Architecture: "qwen3",
538+
Format: "gguf",
539+
ParamSize: "0.6B",
540+
Quantization: "Q8_0",
541+
},
542+
}
543+
544+
m := &mockModel{
545+
id: "sha256:cncf123456789012",
546+
tags: []string{"aistaging/qwen3-cncf:0.6B"},
547+
config: mp,
548+
desc: types.Descriptor{},
549+
}
550+
551+
apiModel, err := ToModel(m)
552+
require.NoError(t, err)
553+
554+
// Config should be normalized to *types.Config
555+
dockerCfg, ok := apiModel.Config.(*types.Config)
556+
require.True(t, ok, "Config should be normalized to *types.Config")
557+
assert.Equal(t, types.FormatGGUF, dockerCfg.Format)
558+
assert.Equal(t, "0.6B", dockerCfg.Parameters)
559+
assert.Equal(t, "Q8_0", dockerCfg.Quantization)
560+
assert.Equal(t, "qwen3", dockerCfg.Architecture)
561+
assert.Equal(t, "0.6B", dockerCfg.Size)
562+
563+
// Verify the JSON output is always Docker format (flat structure)
564+
jsonData, err := json.Marshal(apiModel)
565+
require.NoError(t, err)
566+
567+
var unmarshaled Model
568+
err = json.Unmarshal(jsonData, &unmarshaled)
569+
require.NoError(t, err)
570+
571+
assert.Equal(t, "0.6B", unmarshaled.Config.GetParameters())
572+
assert.Equal(t, "Q8_0", unmarshaled.Config.GetQuantization())
573+
assert.Equal(t, "qwen3", unmarshaled.Config.GetArchitecture())
574+
}
575+
481576
// Helper function to create int32 pointers
482577
func int32Ptr(i int32) *int32 {
483578
return &i

0 commit comments

Comments
 (0)