mirror of
https://github.com/ollama/ollama.git
synced 2026-10-20 18:41:17 +00:00
Add CAPABILITY declarations to Modelfiles and an additive capabilities
field to create requests. Preserve declarations across GGUF and safetensors
creation, inheritance, and Modelfile export.
Require decision capability before scheduling System One requests instead
of matching Qwen architecture/renderer metadata. Retain main's GGUF-only
scoring restriction until the separate MLX runtime work lands.
Extracted from the capability foundation in 36d46a0c3 on system_one_mlx;
MLX scoring and manifest-list changes are intentionally separate.
954 lines
27 KiB
Go
954 lines
27 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"cmp"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"log/slog"
|
||
"math"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"runtime/debug"
|
||
"slices"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/ollama/ollama/api"
|
||
"github.com/ollama/ollama/create"
|
||
"github.com/ollama/ollama/envconfig"
|
||
"github.com/ollama/ollama/format"
|
||
"github.com/ollama/ollama/fs/gguf"
|
||
"github.com/ollama/ollama/manifest"
|
||
"github.com/ollama/ollama/mlx/quant"
|
||
"github.com/ollama/ollama/mlxrunner"
|
||
"github.com/ollama/ollama/types/errtypes"
|
||
"github.com/ollama/ollama/types/model"
|
||
)
|
||
|
||
var (
|
||
errNoFilesProvided = errors.New("no files provided to convert")
|
||
errAdaptersUnsupported = errors.New("LoRA adapters are no longer supported")
|
||
errOnlyGGUFSupported = errors.New("supplied file was not in GGUF format")
|
||
errUnknownType = errors.New("unknown type")
|
||
errNeitherFromOrFiles = errors.New("neither 'from' or 'files' was specified")
|
||
errFilePath = errors.New("file path must be relative")
|
||
errRemoteDraftUnsupported = errors.New("DRAFT cannot be used with remote models")
|
||
errSafetensorsFrom = errors.New("safetensors imports do not support FROM model overlays")
|
||
errInvalidSplitGGUF = errors.New("invalid split GGUF")
|
||
errMixedModelTypes = errors.New("mixed model file types")
|
||
errInvalidCreateInfo = errors.New("invalid create info")
|
||
errTypicalPDeprecated = errors.New("typical_p is deprecated and cannot be set as a model parameter; pass it as a request option instead")
|
||
)
|
||
|
||
const (
|
||
maxSafetensorsMetadataSize = 64 << 20
|
||
maxCreateFiles = 1024
|
||
)
|
||
|
||
func (s *Server) CreateHandler(c *gin.Context) {
|
||
config := new(model.ConfigV2)
|
||
|
||
var r api.CreateRequest
|
||
if err := c.ShouldBindJSON(&r); errors.Is(err, io.EOF) {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing request body"})
|
||
return
|
||
} else if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if r.Parameters["typical_p"] != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": errTypicalPDeprecated.Error()})
|
||
return
|
||
}
|
||
|
||
config.Renderer = r.Renderer
|
||
config.Parser = r.Parser
|
||
config.Requires = r.Requires
|
||
for _, capability := range r.Capabilities {
|
||
if !model.Capability(capability).IsValid() {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown capability: %q", capability)})
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := validateCreateFiles(r.Files); err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := validateCreateFiles(r.DraftFiles); err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if len(r.Adapters) > 0 {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": errAdaptersUnsupported.Error()})
|
||
return
|
||
}
|
||
if _, err := create.LicenseStrings(r.License); err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if r.DraftQuantize != "" && len(r.DraftFiles) == 0 {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "--draft-quantize requires a DRAFT model"})
|
||
return
|
||
}
|
||
|
||
name := model.ParseName(cmp.Or(r.Model, r.Name))
|
||
if !name.IsValid() {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": errtypes.InvalidModelNameErrMsg})
|
||
return
|
||
}
|
||
|
||
name, err := getExistingName(name)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
fileType, err := detectModelTypeFromFiles(r.Files)
|
||
if err != nil {
|
||
// Mixed types and blobs the client never uploaded are request errors;
|
||
// anything else is the server failing to read its own blob store.
|
||
status := http.StatusInternalServerError
|
||
if errors.Is(err, errMixedModelTypes) || errors.Is(err, fs.ErrNotExist) || errors.Is(err, manifest.ErrInvalidDigestFormat) {
|
||
status = http.StatusBadRequest
|
||
}
|
||
c.AbortWithStatusJSON(status, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := validateCreateOptions(r, fileType); err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
reqCtx := c.Request.Context()
|
||
ch := make(chan any)
|
||
go func() {
|
||
send := func(resp any) bool {
|
||
select {
|
||
case ch <- resp:
|
||
return true
|
||
case <-reqCtx.Done():
|
||
return false
|
||
}
|
||
}
|
||
defer close(ch)
|
||
defer recoverCreatePanic(send)
|
||
|
||
fn := func(resp api.ProgressResponse) {
|
||
send(resp)
|
||
}
|
||
|
||
oldManifest, _ := manifest.ParseNamedManifest(name)
|
||
|
||
if fileType == "safetensors" {
|
||
if err := createSafetensorsModel(reqCtx, r, name, fn); err != nil {
|
||
send(createSafetensorsErrorResponse(err))
|
||
return
|
||
}
|
||
if err := pruneOldManifestLayers(oldManifest); err != nil {
|
||
send(gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
send(api.ProgressResponse{Status: "success"})
|
||
return
|
||
}
|
||
|
||
var baseLayers []*modelLayer
|
||
var err error
|
||
var remote bool
|
||
|
||
if r.From != "" {
|
||
slog.Debug("create model from model name", "from", r.From)
|
||
fromRef, err := parseAndValidateModelRef(r.From)
|
||
if err != nil {
|
||
send(gin.H{"error": errtypes.InvalidModelNameErrMsg, "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
|
||
fromName := fromRef.Name
|
||
remoteHost := r.RemoteHost
|
||
if fromRef.Source == modelSourceCloud && remoteHost == "" {
|
||
remoteHost = cloudProxyBaseURL
|
||
}
|
||
|
||
if remoteHost != "" {
|
||
ru, err := remoteURL(remoteHost)
|
||
if err != nil {
|
||
send(gin.H{"error": "bad remote", "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
|
||
config.RemoteModel = fromRef.Base
|
||
config.RemoteHost = ru
|
||
remote = true
|
||
} else {
|
||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||
defer cancel()
|
||
|
||
var baseConfig model.ConfigV2
|
||
baseLayers, baseConfig, err = parseFromModel(ctx, fromName, fn)
|
||
if err != nil {
|
||
send(gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
requestConfig := *config
|
||
*config = baseConfig
|
||
if requestConfig.Renderer != "" {
|
||
config.Renderer = requestConfig.Renderer
|
||
}
|
||
if requestConfig.Parser != "" {
|
||
config.Parser = requestConfig.Parser
|
||
}
|
||
if requestConfig.Requires != "" {
|
||
config.Requires = requestConfig.Requires
|
||
}
|
||
}
|
||
} else if r.Files != nil {
|
||
baseLayers, err = convertModelFromFiles(reqCtx, r.Files, fn)
|
||
if err != nil {
|
||
for _, badReq := range []error{errNoFilesProvided, errOnlyGGUFSupported, errUnknownType, errInvalidSplitGGUF, errMixedModelTypes, errAdaptersUnsupported} {
|
||
if errors.Is(err, badReq) {
|
||
send(gin.H{"error": err.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
}
|
||
send(gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
} else {
|
||
send(gin.H{"error": errNeitherFromOrFiles.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
|
||
if remote && len(r.DraftFiles) > 0 {
|
||
send(gin.H{"error": errRemoteDraftUnsupported.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
|
||
var draftLayers []*modelLayer
|
||
if !remote && r.DraftFiles != nil {
|
||
draftLayers, err = convertDraftModelFromFiles(reqCtx, r.DraftFiles, fn)
|
||
if err != nil {
|
||
for _, badReq := range []error{errNoFilesProvided, errOnlyGGUFSupported, errUnknownType, errFilePath, errInvalidSplitGGUF, errMixedModelTypes, errAdaptersUnsupported} {
|
||
if errors.Is(err, badReq) {
|
||
send(gin.H{"error": err.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
}
|
||
send(gin.H{"error": err.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
}
|
||
|
||
if len(draftLayers) > 0 {
|
||
baseLayers = append(baseLayers, draftLayers...)
|
||
}
|
||
|
||
// Info is not currently exposed by Modelfiles, but allows overriding various
|
||
// config values.
|
||
if err := applyCreateInfo(config, r.Info); err != nil {
|
||
send(gin.H{"error": err.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
config.AddCapabilities(r.Capabilities...)
|
||
|
||
if err := createModel(reqCtx, r, name, baseLayers, config, fn); err != nil {
|
||
if errors.Is(err, create.ErrBadTemplate) || errors.Is(err, create.ErrInvalidRequires) || errors.Is(err, create.ErrInvalidLicense) || errors.Is(err, errInvalidSplitGGUF) {
|
||
send(gin.H{"error": err.Error(), "status": http.StatusBadRequest})
|
||
return
|
||
}
|
||
send(gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := pruneOldManifestLayers(oldManifest); err != nil {
|
||
send(gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
send(api.ProgressResponse{Status: "success"})
|
||
}()
|
||
|
||
if r.Stream != nil && !*r.Stream {
|
||
waitForStream(c, ch)
|
||
return
|
||
}
|
||
|
||
streamResponse(c, ch)
|
||
}
|
||
|
||
func pruneOldManifestLayers(oldManifest *manifest.Manifest) error {
|
||
if envconfig.NoPrune() || oldManifest == nil {
|
||
return nil
|
||
}
|
||
removed, err := oldManifest.RemoveLayers()
|
||
removeGGUFMetadata(removed...)
|
||
return err
|
||
}
|
||
|
||
func recoverCreatePanic(send func(any) bool) {
|
||
if r := recover(); r != nil {
|
||
slog.Error("panic in create background goroutine", "panic", r, "stack", string(debug.Stack()))
|
||
send(gin.H{"error": "internal server error"})
|
||
}
|
||
}
|
||
|
||
// createSafetensorsModel imports uploaded raw safetensors source files by
|
||
// staging them as a normal model directory and running the shared create
|
||
// pipeline on the server.
|
||
func createSafetensorsModel(ctx context.Context, r api.CreateRequest, name model.Name, fn func(resp api.ProgressResponse)) error {
|
||
if len(r.Files) == 0 {
|
||
return errNoFilesProvided
|
||
}
|
||
if r.From != "" {
|
||
return errSafetensorsFrom
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
// Validate Info before staging or writing blobs. writeSafetensorsManifest
|
||
// applies the same overrides to the inferred model config at commit time.
|
||
if err := applyCreateInfo(new(model.ConfigV2), r.Info); err != nil {
|
||
return fmt.Errorf("%w: %v", errInvalidCreateInfo, err)
|
||
}
|
||
|
||
modelDir, cleanup, err := stageSafetensorsSourceFiles(ctx, r.Files)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer cleanup()
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
progressFn := func(status string) {
|
||
fn(api.ProgressResponse{Status: status})
|
||
}
|
||
store := create.ManifestBlobStore{}
|
||
|
||
var draftDir string
|
||
var draftCleanup func()
|
||
if len(r.DraftFiles) > 0 {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
draftDir, draftCleanup, err = stageSafetensorsSourceFiles(ctx, r.DraftFiles)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer draftCleanup()
|
||
}
|
||
|
||
return create.Create(ctx, name.String(), modelDir, create.PipelineOptions{
|
||
Quantize: cmp.Or(r.Quantize, r.Quantization),
|
||
Parser: r.Parser,
|
||
Renderer: r.Renderer,
|
||
Requires: r.Requires,
|
||
DraftDir: draftDir,
|
||
DraftQuantize: r.DraftQuantize,
|
||
}, store, writeSafetensorsManifest(r, draftDir, fn), progressFn)
|
||
}
|
||
|
||
func createSafetensorsErrorResponse(err error) gin.H {
|
||
if errors.Is(err, mlxrunner.ErrRuntimeUnavailable) {
|
||
slog.Warn("MLX runtime unavailable during safetensors create", "error", err)
|
||
return gin.H{"error": mlxrunner.ErrRuntimeUnavailable.Error(), "status": http.StatusServiceUnavailable}
|
||
}
|
||
|
||
status := http.StatusInternalServerError
|
||
for _, badReq := range []error{errNoFilesProvided, errFilePath, errSafetensorsFrom, errInvalidCreateInfo, manifest.ErrInvalidDigestFormat, create.ErrBadTemplate, create.ErrInvalidRequires, create.ErrInvalidLicense, create.ErrUnsupportedMLXArchitecture, os.ErrNotExist} {
|
||
if errors.Is(err, badReq) {
|
||
status = http.StatusBadRequest
|
||
break
|
||
}
|
||
}
|
||
return gin.H{"error": err.Error(), "status": status}
|
||
}
|
||
|
||
func writeSafetensorsManifest(r api.CreateRequest, draftDir string, fn func(resp api.ProgressResponse)) create.ManifestWriter {
|
||
next := create.NewSafetensorsManifestWriter(create.SafetensorsManifestOptions{
|
||
MinVersion: create.SafetensorsMinOllamaVersion,
|
||
DraftDir: draftDir,
|
||
Template: r.Template,
|
||
System: r.System,
|
||
License: r.License,
|
||
Parameters: r.Parameters,
|
||
Messages: r.Messages,
|
||
Capabilities: r.Capabilities,
|
||
BeforeWriteManifest: func() { fn(api.ProgressResponse{Status: "writing manifest"}) },
|
||
})
|
||
return func(ctx context.Context, modelName string, info create.ManifestInfo) error {
|
||
if len(info.ModelConfig.Capabilities) == 0 {
|
||
info.ModelConfig.Capabilities = []string{"completion"}
|
||
}
|
||
if err := applyCreateInfo(&info.ModelConfig, r.Info); err != nil {
|
||
return fmt.Errorf("%w: %v", errInvalidCreateInfo, err)
|
||
}
|
||
return next(ctx, modelName, info)
|
||
}
|
||
}
|
||
|
||
func stageSafetensorsSourceFiles(ctx context.Context, files map[string]string) (string, func(), error) {
|
||
dir, err := os.MkdirTemp("", "ollama-create-safetensors-*")
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
cleanup := func() {
|
||
if err := os.RemoveAll(dir); err != nil {
|
||
slog.Warn("failed to remove staged safetensors source", "dir", dir, "error", err)
|
||
}
|
||
}
|
||
|
||
for filePath, digest := range files {
|
||
if err := ctx.Err(); err != nil {
|
||
cleanup()
|
||
return "", nil, err
|
||
}
|
||
if err := validateCreateFilePath(filePath); err != nil {
|
||
cleanup()
|
||
return "", nil, err
|
||
}
|
||
blobPath, err := manifest.BlobsPath(digest)
|
||
if err != nil {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("invalid digest for %s: %w", filePath, err)
|
||
}
|
||
info, err := os.Stat(blobPath)
|
||
if err != nil {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("blob not found for %s (digest %s): %w", filePath, digest, err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("blob for %s is not a regular file", filePath)
|
||
}
|
||
if isSafetensorsMetadataFile(filePath) && info.Size() > maxSafetensorsMetadataSize {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("metadata file %s is %d bytes, exceeds maximum %d", filePath, info.Size(), maxSafetensorsMetadataSize)
|
||
}
|
||
|
||
dst := filepath.Join(dir, filepath.FromSlash(filePath))
|
||
if err := linkOrCopyFile(ctx, blobPath, dst); err != nil {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("stage %s: %w", filePath, err)
|
||
}
|
||
}
|
||
return dir, cleanup, nil
|
||
}
|
||
|
||
func isSafetensorsMetadataFile(filePath string) bool {
|
||
switch path.Base(filePath) {
|
||
case "config.json", "generation_config.json", "model.safetensors.index.json", "tokenizer_config.json", "chat_template.jinja":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func linkOrCopyFile(ctx context.Context, src, dst string) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Link(src, dst); err == nil {
|
||
return nil
|
||
}
|
||
if err := os.Symlink(src, dst); err == nil {
|
||
return nil
|
||
}
|
||
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, copyErr := io.Copy(out, create.ReaderWithContext(ctx, in))
|
||
closeErr := out.Close()
|
||
if copyErr != nil {
|
||
return copyErr
|
||
}
|
||
return closeErr
|
||
}
|
||
|
||
func applyCreateInfo(config *model.ConfigV2, info map[string]any) error {
|
||
if info == nil {
|
||
return nil
|
||
}
|
||
if caps, ok := info["capabilities"]; ok {
|
||
parsed, err := capabilitiesFromInfo(caps)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
config.Capabilities = parsed
|
||
}
|
||
|
||
setStringFromInfo := func(k string, dst *string) error {
|
||
v, ok := info[k]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
val, ok := v.(string)
|
||
if !ok {
|
||
return fmt.Errorf("info field %q must be a string", k)
|
||
}
|
||
*dst = val
|
||
return nil
|
||
}
|
||
setIntFromInfo := func(k string, dst *int) error {
|
||
v, ok := info[k]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
val, ok := v.(float64)
|
||
if !ok {
|
||
return fmt.Errorf("info field %q must be a number", k)
|
||
}
|
||
if val < 0 || math.Trunc(val) != val || val > float64(maxCreateInfoInt()) {
|
||
return fmt.Errorf("info field %q must be a non-negative integer", k)
|
||
}
|
||
*dst = int(val)
|
||
return nil
|
||
}
|
||
|
||
if err := setStringFromInfo("model_family", &config.ModelFamily); err != nil {
|
||
return err
|
||
}
|
||
if _, ok := info["model_family"]; ok {
|
||
config.ModelFamilies = nil
|
||
if config.ModelFamily != "" {
|
||
config.ModelFamilies = []string{config.ModelFamily}
|
||
}
|
||
}
|
||
if err := setStringFromInfo("base_name", &config.BaseName); err != nil {
|
||
return err
|
||
}
|
||
if err := setStringFromInfo("quantization_level", &config.FileType); err != nil {
|
||
return err
|
||
}
|
||
if err := setStringFromInfo("parameter_size", &config.ModelType); err != nil {
|
||
return err
|
||
}
|
||
if err := setIntFromInfo("context_length", &config.ContextLen); err != nil {
|
||
return err
|
||
}
|
||
if err := setIntFromInfo("embedding_length", &config.EmbedLen); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func capabilitiesFromInfo(v any) ([]string, error) {
|
||
switch caps := v.(type) {
|
||
case []string:
|
||
return append([]string(nil), caps...), nil
|
||
case []any:
|
||
out := make([]string, len(caps))
|
||
for i, c := range caps {
|
||
str, ok := c.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("info field %q element %d must be a string", "capabilities", i)
|
||
}
|
||
out[i] = str
|
||
}
|
||
return out, nil
|
||
default:
|
||
return nil, fmt.Errorf("info field %q must be an array of strings", "capabilities")
|
||
}
|
||
}
|
||
|
||
func remoteURL(raw string) (string, error) {
|
||
// Special‑case: user supplied only a path ("/foo/bar").
|
||
if strings.HasPrefix(raw, "/") {
|
||
return (&url.URL{
|
||
Scheme: "http",
|
||
Host: net.JoinHostPort("localhost", "11434"),
|
||
Path: path.Clean(raw),
|
||
}).String(), nil
|
||
}
|
||
|
||
if !strings.Contains(raw, "://") {
|
||
raw = "http://" + raw
|
||
}
|
||
|
||
if raw == "ollama.com" || raw == "http://ollama.com" {
|
||
raw = "https://ollama.com:443"
|
||
}
|
||
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse error: %w", err)
|
||
}
|
||
|
||
if u.Host == "" {
|
||
u.Host = "localhost"
|
||
}
|
||
|
||
hostPart, portPart, err := net.SplitHostPort(u.Host)
|
||
if err == nil {
|
||
u.Host = net.JoinHostPort(hostPart, portPart)
|
||
} else {
|
||
u.Host = net.JoinHostPort(u.Host, "11434")
|
||
}
|
||
|
||
if u.Path != "" {
|
||
u.Path = path.Clean(u.Path)
|
||
}
|
||
|
||
if u.Path == "/" {
|
||
u.Path = ""
|
||
}
|
||
|
||
return u.String(), nil
|
||
}
|
||
|
||
func convertModelFromFiles(ctx context.Context, files map[string]string, fn func(resp api.ProgressResponse)) ([]*modelLayer, error) {
|
||
return convertModelFromFilesWithMediaType(ctx, files, "", true, fn)
|
||
}
|
||
|
||
func convertDraftModelFromFiles(ctx context.Context, files map[string]string, fn func(resp api.ProgressResponse)) ([]*modelLayer, error) {
|
||
return convertModelFromFilesWithMediaType(ctx, files, manifest.MediaTypeImageDraft, false, fn)
|
||
}
|
||
|
||
func convertModelFromFilesWithMediaType(ctx context.Context, files map[string]string, mediaType string, detectTemplate bool, fn func(resp api.ProgressResponse)) ([]*modelLayer, error) {
|
||
modelType, err := detectModelTypeFromFiles(files)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch modelType {
|
||
case "safetensors":
|
||
return nil, errOnlyGGUFSupported
|
||
case "gguf":
|
||
if len(files) == 0 {
|
||
return nil, errNoFilesProvided
|
||
}
|
||
|
||
filePaths := make([]string, 0, len(files))
|
||
for filePath := range files {
|
||
filePaths = append(filePaths, filePath)
|
||
}
|
||
slices.Sort(filePaths)
|
||
|
||
splitCollector := newSplitGGUFCollector()
|
||
for _, filePath := range filePaths {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
layers, err := ggufLayersWithMediaType(files[filePath], filePath, mediaType, fn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, layer := range layers {
|
||
if err := splitCollector.Add(layer); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
|
||
allLayers, err := splitCollector.Layers()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if detectTemplate {
|
||
return detectChatTemplate(allLayers)
|
||
}
|
||
return allLayers, nil
|
||
default:
|
||
return nil, errUnknownType
|
||
}
|
||
}
|
||
|
||
func validateCreateFiles(files map[string]string) error {
|
||
if len(files) > maxCreateFiles {
|
||
return fmt.Errorf("too many files: %d exceeds maximum %d", len(files), maxCreateFiles)
|
||
}
|
||
for filePath, digest := range files {
|
||
if err := validateCreateFilePath(filePath); err != nil {
|
||
return err
|
||
}
|
||
if digest == "" {
|
||
return manifest.ErrInvalidDigestFormat
|
||
}
|
||
if _, err := manifest.BlobsPath(digest); err != nil {
|
||
return fmt.Errorf("invalid digest for %s: %w", filePath, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateCreateFilePath(filePath string) error {
|
||
if filePath == "." || !fs.ValidPath(filePath) || strings.ContainsAny(filePath, `\:`) {
|
||
return fmt.Errorf("%w: %s", errFilePath, filePath)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateCreateOptions(r api.CreateRequest, modelType string) error {
|
||
quantize := cmp.Or(r.Quantize, r.Quantization)
|
||
if modelType == "gguf" || (modelType == "" && r.From != "") {
|
||
if quantize != "" {
|
||
return fmt.Errorf("create-time quantization is only supported for safetensors imports; quantize GGUF models with llama.cpp tools before importing")
|
||
}
|
||
if r.DraftQuantize != "" {
|
||
return fmt.Errorf("draft quantization during create is only supported for safetensors imports; quantize GGUF draft models with llama.cpp tools before importing")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
if quantize != "" && quant.Canonical(quantize) == "" {
|
||
return fmt.Errorf("unsupported quantize type %q: supported types are int4, int8, nvfp4, mxfp4, mxfp8", quantize)
|
||
}
|
||
if r.DraftQuantize != "" && quant.Canonical(r.DraftQuantize) == "" {
|
||
return fmt.Errorf("unsupported draft quantize type %q: supported types are int4, int8, nvfp4, mxfp4, mxfp8", r.DraftQuantize)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func maxCreateInfoInt() int {
|
||
return int(^uint(0) >> 1)
|
||
}
|
||
|
||
func detectModelTypeFromFiles(files map[string]string) (string, error) {
|
||
filePaths := make([]string, 0, len(files))
|
||
for filePath := range files {
|
||
filePaths = append(filePaths, filePath)
|
||
}
|
||
slices.Sort(filePaths)
|
||
|
||
var modelType string
|
||
for _, filePath := range filePaths {
|
||
t, err := detectModelTypeFromFile(filePath, files[filePath])
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if t == "" {
|
||
continue
|
||
}
|
||
if modelType != "" && modelType != t {
|
||
return "", fmt.Errorf("%w: found both %s and %s inputs", errMixedModelTypes, modelType, t)
|
||
}
|
||
modelType = t
|
||
}
|
||
|
||
return modelType, nil
|
||
}
|
||
|
||
func detectModelTypeFromFile(filePath, digest string) (string, error) {
|
||
if strings.HasSuffix(filePath, ".safetensors") {
|
||
return "safetensors", nil
|
||
}
|
||
if strings.HasSuffix(filePath, ".gguf") {
|
||
return "gguf", nil
|
||
}
|
||
|
||
// Try to detect GGUF files even when the source name has no extension.
|
||
blobPath, err := manifest.BlobsPath(digest)
|
||
if err != nil {
|
||
return "", fmt.Errorf("blob path for %s: %w", filePath, err)
|
||
}
|
||
|
||
f, err := os.Open(blobPath)
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return "", fmt.Errorf("blob not found for %s (digest %s): %w", filePath, digest, err)
|
||
} else if err != nil {
|
||
return "", fmt.Errorf("read %s: %w", filePath, err)
|
||
}
|
||
defer f.Close()
|
||
|
||
buf := make([]byte, 4)
|
||
if _, err := io.ReadFull(f, buf); err != nil {
|
||
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||
return "", fmt.Errorf("read %s: %w", filePath, err)
|
||
}
|
||
return "", nil
|
||
}
|
||
if bytes.Equal(buf, []byte("GGUF")) || bytes.Equal(buf, []byte("FUGG")) {
|
||
return "gguf", nil
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
func createModel(ctx context.Context, r api.CreateRequest, name model.Name, baseLayers []*modelLayer, config *model.ConfigV2, fn func(resp api.ProgressResponse)) (err error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if quantize := cmp.Or(r.Quantize, r.Quantization); quantize != "" {
|
||
return fmt.Errorf("create-time quantization is only supported for safetensors imports; quantize GGUF models with llama.cpp tools before importing")
|
||
}
|
||
if r.DraftQuantize != "" {
|
||
return fmt.Errorf("draft quantization during create is only supported for safetensors imports; quantize GGUF draft models with llama.cpp tools before importing")
|
||
}
|
||
|
||
var layers []manifest.Layer
|
||
hasSplitGGUF := false
|
||
for _, layer := range baseLayers {
|
||
if len(layer.splitLayers) > 0 {
|
||
hasSplitGGUF = true
|
||
layers = append(layers, layer.splitLayers...)
|
||
} else {
|
||
layers = append(layers, layer.Layer)
|
||
}
|
||
|
||
if layer.GGUF != nil {
|
||
switch layer.MediaType {
|
||
case "application/vnd.ollama.image.model":
|
||
config.ModelFormat = cmp.Or(config.ModelFormat, "gguf")
|
||
config.ModelFamily = cmp.Or(config.ModelFamily, layer.GGUF.Architecture())
|
||
config.ModelType = cmp.Or(config.ModelType, format.HumanNumber(layer.parameterCount))
|
||
config.FileType = cmp.Or(config.FileType, layer.GGUF.FileType().String())
|
||
architecture := layer.GGUF.Architecture()
|
||
if !slices.Contains(config.ModelFamilies, architecture) {
|
||
config.ModelFamilies = append(config.ModelFamilies, architecture)
|
||
}
|
||
|
||
// Auto-detect renderer, parser, and stop tokens from GGUF architecture.
|
||
if config.Renderer == "" || config.Parser == "" {
|
||
arch := layer.GGUF.Architecture()
|
||
switch arch {
|
||
case "gemma4":
|
||
config.Renderer = cmp.Or(config.Renderer, gemma4RendererLegacy)
|
||
config.Parser = cmp.Or(config.Parser, "gemma4")
|
||
if _, ok := r.Parameters["stop"]; !ok {
|
||
if r.Parameters == nil {
|
||
r.Parameters = make(map[string]any)
|
||
}
|
||
r.Parameters["stop"] = []string{"<turn|>"}
|
||
}
|
||
case "laguna":
|
||
config.Renderer = cmp.Or(config.Renderer, "laguna")
|
||
config.Parser = cmp.Or(config.Parser, "laguna")
|
||
case "nemotron_h", "nemotron_h_moe", "nemotron_h_omni":
|
||
config.Renderer = cmp.Or(config.Renderer, "nemotron-3-nano")
|
||
config.Parser = cmp.Or(config.Parser, "nemotron-3-nano")
|
||
}
|
||
}
|
||
case manifest.MediaTypeImageDraft:
|
||
config.Draft = &model.Draft{
|
||
ModelFormat: "gguf",
|
||
Architecture: layer.GGUF.Architecture(),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if hasSplitGGUF {
|
||
if config.Requires == "" {
|
||
config.Requires = splitGGUFMinOllamaVersion
|
||
}
|
||
}
|
||
|
||
layers, err = create.ApplyModelfileLayers(layers, create.ModelfileLayerOptions{
|
||
Template: r.Template,
|
||
System: r.System,
|
||
License: r.License,
|
||
Parameters: r.Parameters,
|
||
Messages: r.Messages,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
configLayer, err := createConfigLayer(*config)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, layer := range layers {
|
||
if layer.Status != "" {
|
||
fn(api.ProgressResponse{Status: layer.Status})
|
||
}
|
||
}
|
||
|
||
fn(api.ProgressResponse{Status: "writing manifest"})
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if err := manifest.WriteManifest(name, *configLayer, layers); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func ggufLayersWithMediaType(digest, sourceName, mediaType string, fn func(resp api.ProgressResponse)) ([]*modelLayer, error) {
|
||
var layers []*modelLayer
|
||
|
||
fn(api.ProgressResponse{Status: "parsing GGUF"})
|
||
blobPath, err := manifest.BlobsPath(digest)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
metadata, err := gguf.ReadFileMetadata(blobPath, 1)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if metadata.Kind() == "adapter" {
|
||
return nil, fmt.Errorf("%w: %s is a LoRA adapter", errAdaptersUnsupported, sourceName)
|
||
}
|
||
if mediaType == "" {
|
||
mediaType = "application/vnd.ollama.image.model"
|
||
if isProjectorGGUF(metadata) {
|
||
mediaType = "application/vnd.ollama.image.projector"
|
||
}
|
||
}
|
||
|
||
layer, err := manifest.NewLayerFromLayer(digest, mediaType, sourceName)
|
||
if err != nil {
|
||
slog.Debug("could not create new layer from layer", "error", err)
|
||
return nil, err
|
||
}
|
||
|
||
layers = append(layers, &modelLayer{
|
||
Layer: layer,
|
||
GGUF: metadata,
|
||
parameterCount: metadata.ParameterCount(),
|
||
splitFile: sourceName,
|
||
})
|
||
|
||
return layers, nil
|
||
}
|
||
|
||
func isProjectorGGUF(metadata *gguf.Metadata) bool {
|
||
switch metadata.Kind() {
|
||
case "projector", "mmproj":
|
||
return true
|
||
}
|
||
|
||
// If a model has vision.block_count but not block_count, it is a standalone vision model.
|
||
if metadata.BlockCount() == 0 && metadata.Uint("vision.block_count") > 0 {
|
||
return true
|
||
}
|
||
|
||
return metadata.Architecture() == "clip" && metadata.BlockCount() == 0 &&
|
||
(metadata.Bool("has_vision_encoder") || metadata.Bool("has_audio_encoder"))
|
||
}
|
||
|
||
func createConfigLayer(config model.ConfigV2) (*manifest.Layer, error) {
|
||
var b bytes.Buffer
|
||
if err := json.NewEncoder(&b).Encode(config); err != nil {
|
||
return nil, err
|
||
}
|
||
layer, err := manifest.NewLayer(&b, "application/vnd.docker.container.image.v1+json")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &layer, nil
|
||
}
|