Skip to content
Merged
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
23 changes: 21 additions & 2 deletions pkg/distribution/internal/partial/partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,29 @@ func ConfigFile(i WithRawConfigFile) (*types.ConfigFile, error) {
}

// Descriptor returns the types.Descriptor for the model.
// Supports both Docker format (where created is in descriptor.created)
// and CNCF ModelPack format (where created is in descriptor.createdAt).
func Descriptor(i WithRawConfigFile) (types.Descriptor, error) {
cf, err := ConfigFile(i)
raw, err := i.RawConfigFile()
if err != nil {
return types.Descriptor{}, fmt.Errorf("config file: %w", err)
return types.Descriptor{}, fmt.Errorf("get raw config file: %w", err)
}

// ModelPack format: extract createdAt from the ModelPack descriptor.
// Docker's types.Descriptor uses "created" (snake_case) while ModelPack
// uses "createdAt" (camelCase), so we must parse them separately.
if modelpack.IsModelPackConfig(raw) {
var mp modelpack.Model
if err := json.Unmarshal(raw, &mp); err != nil {
return types.Descriptor{}, fmt.Errorf("unmarshal modelpack config: %w", err)
}
return types.Descriptor{Created: mp.Descriptor.CreatedAt}, nil
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
}

// Docker format
var cf types.ConfigFile
if err := json.Unmarshal(raw, &cf); err != nil {
return types.Descriptor{}, fmt.Errorf("unmarshal config: %w", err)
}
return cf.Descriptor, nil
}
Expand Down
24 changes: 22 additions & 2 deletions pkg/inference/models/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,29 @@ package models
import (
"fmt"

"github.com/docker/model-runner/pkg/distribution/modelpack"
"github.com/docker/model-runner/pkg/distribution/types"
)

// normalizeConfig converts a ModelPack config to Docker format types.Config
// so that the API wire format is always consistent. This ensures clients
// don't need to understand both config formats.
func normalizeConfig(cfg types.ModelConfig) types.ModelConfig {
if cfg == nil {
return nil
}
if _, ok := cfg.(*modelpack.Model); ok {
return &types.Config{
Format: cfg.GetFormat(),
Parameters: cfg.GetParameters(),
Quantization: cfg.GetQuantization(),
Architecture: cfg.GetArchitecture(),
Size: cfg.GetSize(),
}
Comment on lines +18 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The normalizeConfig function omits the ContextSize field when converting from modelpack.Model to types.Config. While the current ModelPack implementation returns nil for context size, it is better to include it for completeness and to ensure the normalized types.Config fully represents the ModelConfig interface. This also makes the normalization logic more robust if ModelPack adds support for this field in the future.

return &types.Config{
			Format:       cfg.GetFormat(),
			Parameters:   cfg.GetParameters(),
			Quantization: cfg.GetQuantization(),
			Architecture: cfg.GetArchitecture(),
			Size:         cfg.GetSize(),
			ContextSize:  cfg.GetContextSize(),
		}
References
  1. Pragmatism and correctness: ensure the normalized object fully represents the source interface to avoid silent data loss or inconsistent API responses. (link)

}
return cfg
}

func ToModel(m types.Model) (*Model, error) {
desc, err := m.Descriptor()
if err != nil {
Expand All @@ -31,7 +51,7 @@ func ToModel(m types.Model) (*Model, error) {
ID: id,
Tags: m.Tags(),
Created: created,
Config: cfg,
Config: normalizeConfig(cfg),
}, nil
}

Expand Down Expand Up @@ -62,6 +82,6 @@ func ToModelFromArtifact(artifact types.ModelArtifact) (*Model, error) {
ID: id,
Tags: nil, // Remote models don't have local tags
Created: created,
Config: cfg,
Config: normalizeConfig(cfg),
}, nil
}
95 changes: 95 additions & 0 deletions pkg/inference/models/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"testing"

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

func TestNormalizeConfigModelPack(t *testing.T) {
// Test that normalizeConfig converts ModelPack config to Docker format.
// This simulates what happens in ToModel() when the server normalizes
// CNCF configs before serializing the API response.
mp := &modelpack.Model{
Descriptor: modelpack.ModelDescriptor{
Family: "qwen3",
},
Config: modelpack.ModelConfig{
Architecture: "qwen3",
Format: "safetensors",
ParamSize: "0.6B",
Quantization: "F16",
},
ModelFS: modelpack.ModelFS{
Type: "layers",
},
}

normalized := normalizeConfig(mp)
require.NotNil(t, normalized)

// Should be converted to *types.Config
dockerCfg, ok := normalized.(*types.Config)
require.True(t, ok, "Normalized config should be *types.Config")
assert.Equal(t, types.FormatSafetensors, dockerCfg.Format)
assert.Equal(t, "0.6B", dockerCfg.Parameters)
assert.Equal(t, "F16", dockerCfg.Quantization)
assert.Equal(t, "qwen3", dockerCfg.Architecture)
assert.Equal(t, "0.6B", dockerCfg.Size)
}

func TestNormalizeConfigDocker(t *testing.T) {
// Docker format configs should pass through unchanged.
dockerCfg := &types.Config{
Format: "gguf",
Parameters: "7B",
Quantization: "Q4_K_M",
Architecture: "llama",
Size: "7B",
}

normalized := normalizeConfig(dockerCfg)
assert.Equal(t, dockerCfg, normalized, "Docker config should pass through unchanged")
}

func TestNormalizeConfigNil(t *testing.T) {
assert.Nil(t, normalizeConfig(nil), "nil config should return nil")
}

func TestToModelWithModelPackConfig(t *testing.T) {
// Test that ToModel properly normalizes a ModelPack config and
// the resulting JSON is always in Docker format.
mp := &modelpack.Model{
Config: modelpack.ModelConfig{
Architecture: "qwen3",
Format: "gguf",
ParamSize: "0.6B",
Quantization: "Q8_0",
},
}

m := &mockModel{
id: "sha256:cncf123456789012",
tags: []string{"aistaging/qwen3-cncf:0.6B"},
config: mp,
desc: types.Descriptor{},
}

apiModel, err := ToModel(m)
require.NoError(t, err)

// Config should be normalized to *types.Config
dockerCfg, ok := apiModel.Config.(*types.Config)
require.True(t, ok, "Config should be normalized to *types.Config")
assert.Equal(t, types.FormatGGUF, dockerCfg.Format)
assert.Equal(t, "0.6B", dockerCfg.Parameters)
assert.Equal(t, "Q8_0", dockerCfg.Quantization)
assert.Equal(t, "qwen3", dockerCfg.Architecture)
assert.Equal(t, "0.6B", dockerCfg.Size)

// Verify the JSON output is always Docker format (flat structure)
jsonData, err := json.Marshal(apiModel)
require.NoError(t, err)

var unmarshaled Model
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)

assert.Equal(t, "0.6B", unmarshaled.Config.GetParameters())
assert.Equal(t, "Q8_0", unmarshaled.Config.GetQuantization())
assert.Equal(t, "qwen3", unmarshaled.Config.GetArchitecture())
}

// Helper function to create int32 pointers
func int32Ptr(i int32) *int32 {
return &i
Expand Down
Loading