mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 21:48:02 -05:00
The MLX runner is the only Go inference runner left and is no longer experimental, so its packages leave x/. The bindings become a top-level mlx package beside the carried patches in mlx/compat, mirroring how llama/ holds the llama.cpp integration, and the runner becomes mlxrunner with the architectures nested under the package they implement. Subpackages move with their parent unless listed. x/mlxrunner/mlx mlx x/internal/mlxthread mlx/mlxthread x/internal/mlxthreadtest mlx/mlxthread/mlxthreadtest x/internal/mlxtest mlx/mlxtest x/quant mlx/quant mlx/compat/*.patch mlx/compat/mlx-c (MLX patches go in mlx/compat/mlx) x/mlxrunner mlxrunner x/models/nn mlxrunner/nn x/models/<arch> mlxrunner/model/<arch> x/mlxrunner/imports.go mlxrunner/model/architectures (new package) x/create create x/safetensors fs/safetensors x/tokenizer mlxrunner/tokenizer Every package keeps its name, so the Go changes are the import path rewrites the moves force, and the CMake, Dockerfile, CI cache keys, drift check and Darwin payload script follow the new paths. Four edits are not paths: the runner's blank architecture imports become the package mlxrunner/model/architectures, so the list to extend for a new model sits beside the architecture directories; a depguard rule keeps the two test harnesses out of non-test code, as the x/internal placement used to; the CI change filter's two entries for the long-deleted x/imagegen/mlx now name the bindings' CMake project and the carried patches, so a change to either builds the payload; and the tokenizer parity test reads its fixtures from its own testdata instead of walking out of x/. x/server and x/imagegen/manifest stay for the next two commits.
946 lines
27 KiB
Go
946 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")
|
||
)
|
||
|
||
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": errTypicalPUnsupported.Error()})
|
||
return
|
||
}
|
||
|
||
config.Renderer = r.Renderer
|
||
config.Parser = r.Parser
|
||
config.Requires = r.Requires
|
||
|
||
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
|
||
}
|
||
|
||
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,
|
||
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
|
||
}
|