create: Clean up experimental paths, fix create from existing safetensor model (#14679)

* create:  Clean up experimental paths

This cleans up the experimental features, and adds both unit and integration test coverage to verify no regressions.

* create: preserve config and layer names when creating from safetensors models

When creating a model FROM an existing safetensors model, ModelFormat,
Capabilities, and layer Name fields were lost. ModelFormat stayed empty
because it's only set from GGML layers (which safetensors models lack),
and layer names weren't copied in parseFromModel. This caused derived
models to fail loading ("config.json not found in manifest").

* review comments
This commit is contained in:
Daniel Hiltgen
2026-04-07 08:12:57 -07:00
committed by GitHub
parent e823bff873
commit 30fdd229a4
15 changed files with 1292 additions and 22 deletions

View File

@@ -141,7 +141,7 @@ func (s *Server) CreateHandler(c *gin.Context) {
ch <- gin.H{"error": err.Error()}
}
if err == nil && !remote && (config.Renderer == "" || config.Parser == "" || config.Requires == "" || len(config.Capabilities) == 0) {
if err == nil && !remote {
mf, mErr := manifest.ParseNamedManifest(fromName)
if mErr == nil && mf.Config.Digest != "" {
configPath, pErr := manifest.BlobsPath(mf.Config.Digest)
@@ -158,6 +158,9 @@ func (s *Server) CreateHandler(c *gin.Context) {
if config.Requires == "" {
config.Requires = baseConfig.Requires
}
if config.ModelFormat == "" {
config.ModelFormat = baseConfig.ModelFormat
}
if len(config.Capabilities) == 0 {
config.Capabilities = baseConfig.Capabilities
}

View File

@@ -41,11 +41,12 @@ func parseFromModel(ctx context.Context, name model.Name, fn func(api.ProgressRe
return nil, err
}
for _, layer := range m.Layers {
layer, err := manifest.NewLayerFromLayer(layer.Digest, layer.MediaType, name.DisplayShortest())
for _, srcLayer := range m.Layers {
layer, err := manifest.NewLayerFromLayer(srcLayer.Digest, srcLayer.MediaType, name.DisplayShortest())
if err != nil {
return nil, err
}
layer.Name = srcLayer.Name
switch layer.MediaType {
case "application/vnd.ollama.image.model",

View File

@@ -1024,3 +1024,272 @@ func TestDetectModelTypeFromFiles(t *testing.T) {
}
})
}
// createTestBlob creates a blob in the blobs directory and returns its digest.
func createTestBlob(t *testing.T, data []byte) string {
t.Helper()
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
blobPath, err := manifest.BlobsPath(digest)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(blobPath, data, 0o644); err != nil {
t.Fatal(err)
}
return digest
}
// createSafetensorsTestModel creates a minimal safetensors model manifest for testing.
func createSafetensorsTestModel(t *testing.T, modelName string, config model.ConfigV2, extraLayers []manifest.Layer) {
t.Helper()
// Create a fake tensor blob
tensorData := []byte("fake-tensor-data-for-testing")
tensorDigest := createTestBlob(t, tensorData)
layers := []manifest.Layer{
{
MediaType: manifest.MediaTypeImageTensor,
Digest: tensorDigest,
Size: int64(len(tensorData)),
Name: "model.embed_tokens.weight",
},
}
layers = append(layers, extraLayers...)
configLayer, err := createConfigLayer(layers, config)
if err != nil {
t.Fatalf("failed to create config layer: %v", err)
}
name := model.ParseName(modelName)
if err := manifest.WriteManifest(name, *configLayer, layers); err != nil {
t.Fatalf("failed to write manifest: %v", err)
}
}
func TestCreateFromSafetensorsModel_PreservesConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create a source safetensors model with specific config fields
createSafetensorsTestModel(t, "source-model", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
Requires: "0.14.0",
Renderer: "gemma3",
Parser: "gemma3",
}, nil)
// Create a derived model FROM the source
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-model",
From: "source-model",
System: "You are a pirate.",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Read the derived model's config
derivedName := model.ParseName("derived-model")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
configBlobPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("failed to get config blob path: %v", err)
}
configBlob, err := os.ReadFile(configBlobPath)
if err != nil {
t.Fatalf("failed to read config blob: %v", err)
}
var cfg model.ConfigV2
if err := json.Unmarshal(configBlob, &cfg); err != nil {
t.Fatalf("failed to unmarshal config: %v", err)
}
// Verify safetensors-specific config fields are preserved
if cfg.ModelFormat != "safetensors" {
t.Errorf("ModelFormat = %q, want %q", cfg.ModelFormat, "safetensors")
}
if !slices.Contains(cfg.Capabilities, "completion") {
t.Errorf("Capabilities = %v, want to contain %q", cfg.Capabilities, "completion")
}
if cfg.Requires != "0.14.0" {
t.Errorf("Requires = %q, want %q", cfg.Requires, "0.14.0")
}
if cfg.Renderer != "gemma3" {
t.Errorf("Renderer = %q, want %q", cfg.Renderer, "gemma3")
}
if cfg.Parser != "gemma3" {
t.Errorf("Parser = %q, want %q", cfg.Parser, "gemma3")
}
// Verify system prompt was added
var hasSystem bool
for _, l := range mf.Layers {
if l.MediaType == "application/vnd.ollama.image.system" {
hasSystem = true
break
}
}
if !hasSystem {
t.Error("expected system prompt layer in derived model")
}
// Verify tensor layers were copied with names preserved
var tensorNames []string
for _, l := range mf.Layers {
if l.MediaType == manifest.MediaTypeImageTensor {
tensorNames = append(tensorNames, l.Name)
}
}
if len(tensorNames) == 0 {
t.Error("expected tensor layers in derived model")
}
for _, name := range tensorNames {
if name == "" {
t.Error("tensor layer has empty name — names must be preserved from source")
}
}
}
func TestCreateFromSafetensorsModel_OverrideSystem(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create source with a system prompt
createSafetensorsTestModel(t, "source-with-system", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
}, nil)
// First create with system prompt
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "source-with-system",
From: "source-with-system",
System: "Original system prompt",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Now create a derived model with a different system prompt
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-new-system",
From: "source-with-system",
System: "New system prompt",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify ModelFormat is preserved even after override
derivedName := model.ParseName("derived-new-system")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
configBlobPath, _ := manifest.BlobsPath(mf.Config.Digest)
configBlob, _ := os.ReadFile(configBlobPath)
var cfg model.ConfigV2
json.Unmarshal(configBlob, &cfg)
if cfg.ModelFormat != "safetensors" {
t.Errorf("ModelFormat = %q, want %q", cfg.ModelFormat, "safetensors")
}
}
func TestCreateFromSafetensorsModel_PreservesLayerNames(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create JSON config blobs to include as layers
configJSON := []byte(`{"architectures": ["LlamaForCausalLM"], "model_type": "llama"}`)
configDigest := createTestBlob(t, configJSON)
tokenizerJSON := []byte(`{"version": "1.0"}`)
tokenizerDigest := createTestBlob(t, tokenizerJSON)
extraLayers := []manifest.Layer{
{
MediaType: "application/vnd.ollama.image.json",
Digest: configDigest,
Size: int64(len(configJSON)),
Name: "config.json",
},
{
MediaType: "application/vnd.ollama.image.json",
Digest: tokenizerDigest,
Size: int64(len(tokenizerJSON)),
Name: "tokenizer.json",
},
}
createSafetensorsTestModel(t, "source-named-layers", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
}, extraLayers)
// Create derived model
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-named-layers",
From: "source-named-layers",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
derivedName := model.ParseName("derived-named-layers")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
// Check tensor layer names are preserved
for _, l := range mf.Layers {
if l.MediaType == manifest.MediaTypeImageTensor && l.Name == "" {
t.Error("tensor layer has empty name — names must be preserved from source")
}
}
// Check JSON layer names are preserved
jsonNames := make(map[string]bool)
for _, l := range mf.Layers {
if l.MediaType == "application/vnd.ollama.image.json" && l.Name != "" {
jsonNames[l.Name] = true
}
}
if !jsonNames["config.json"] {
t.Error("config.json layer name not preserved in derived model")
}
if !jsonNames["tokenizer.json"] {
t.Error("tokenizer.json layer name not preserved in derived model")
}
}