|
| 1 | +package hub |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "strings" |
| 9 | + "time" |
| 10 | +) |
| 11 | + |
| 12 | +// ───────────────────────────────────────────────────────────────────────────── |
| 13 | +// Ollama import — shared by the CLI (`vortelio import-ollama`) and the HTTP |
| 14 | +// handler (/api/import/ollama), so importing works with or without a running |
| 15 | +// server. |
| 16 | +// |
| 17 | +// Ollama stores manifests at ~/.ollama/models/manifests/<registry>/<ns>/<name>/<tag> |
| 18 | +// and blobs at ~/.ollama/models/blobs/sha256-<digest>. We register them in |
| 19 | +// Vortelio's manifest store WITHOUT copying — local_path points to the existing |
| 20 | +// Ollama blob, so disk usage doesn't double. |
| 21 | +// ───────────────────────────────────────────────────────────────────────────── |
| 22 | + |
| 23 | +type ollamaManifest struct { |
| 24 | + SchemaVersion int `json:"schemaVersion"` |
| 25 | + MediaType string `json:"mediaType"` |
| 26 | + Layers []struct { |
| 27 | + MediaType string `json:"mediaType"` |
| 28 | + Digest string `json:"digest"` |
| 29 | + Size int64 `json:"size"` |
| 30 | + } `json:"layers"` |
| 31 | + Config struct { |
| 32 | + Digest string `json:"digest"` |
| 33 | + } `json:"config"` |
| 34 | +} |
| 35 | + |
| 36 | +// OllamaImportItem is one imported or skipped model. |
| 37 | +type OllamaImportItem struct { |
| 38 | + Model string `json:"model"` |
| 39 | + Size int64 `json:"size,omitempty"` |
| 40 | + Path string `json:"path,omitempty"` |
| 41 | + Reason string `json:"reason,omitempty"` |
| 42 | +} |
| 43 | + |
| 44 | +// OllamaImportResult is the outcome of an import run. |
| 45 | +type OllamaImportResult struct { |
| 46 | + OllamaPath string `json:"ollama_path"` |
| 47 | + DryRun bool `json:"dry_run"` |
| 48 | + Imported []OllamaImportItem `json:"imported"` |
| 49 | + Skipped []OllamaImportItem `json:"skipped"` |
| 50 | + Count int `json:"count"` |
| 51 | +} |
| 52 | + |
| 53 | +// OllamaDefaultDir returns the Ollama root (~/.ollama or from OLLAMA_MODELS). |
| 54 | +func OllamaDefaultDir() string { |
| 55 | + if v := os.Getenv("OLLAMA_MODELS"); v != "" { |
| 56 | + return filepath.Dir(filepath.Dir(v)) // OLLAMA_MODELS points to .../models |
| 57 | + } |
| 58 | + home, _ := os.UserHomeDir() |
| 59 | + return filepath.Join(home, ".ollama") |
| 60 | +} |
| 61 | + |
| 62 | +// ImportOllama scans an Ollama installation and registers its models in |
| 63 | +// Vortelio's store (blobs are referenced in place, not copied). If root is |
| 64 | +// empty, the default Ollama directory is used. On dryRun nothing is saved. |
| 65 | +func ImportOllama(root string, dryRun bool) (OllamaImportResult, error) { |
| 66 | + if root == "" { |
| 67 | + root = OllamaDefaultDir() |
| 68 | + } |
| 69 | + manifestsDir := filepath.Join(root, "models", "manifests") |
| 70 | + blobsDir := filepath.Join(root, "models", "blobs") |
| 71 | + |
| 72 | + res := OllamaImportResult{OllamaPath: root, DryRun: dryRun} |
| 73 | + |
| 74 | + if _, err := os.Stat(manifestsDir); err != nil { |
| 75 | + return res, fmt.Errorf("Ollama installation not found at %s", root) |
| 76 | + } |
| 77 | + |
| 78 | + store := NewModelStore() |
| 79 | + |
| 80 | + filepath.WalkDir(manifestsDir, func(path string, d os.DirEntry, err error) error { |
| 81 | + if err != nil || d.IsDir() { |
| 82 | + return nil |
| 83 | + } |
| 84 | + // path looks like: .../manifests/registry.ollama.ai/library/llama3/8b |
| 85 | + // The tag is the filename, the model is the parent dir. |
| 86 | + rel, _ := filepath.Rel(manifestsDir, path) |
| 87 | + parts := strings.Split(filepath.ToSlash(rel), "/") |
| 88 | + if len(parts) < 3 { |
| 89 | + return nil |
| 90 | + } |
| 91 | + tag := parts[len(parts)-1] |
| 92 | + name := parts[len(parts)-2] |
| 93 | + |
| 94 | + data, err := os.ReadFile(path) |
| 95 | + if err != nil { |
| 96 | + return nil |
| 97 | + } |
| 98 | + var mf ollamaManifest |
| 99 | + if err := json.Unmarshal(data, &mf); err != nil { |
| 100 | + return nil |
| 101 | + } |
| 102 | + |
| 103 | + // Find the largest blob — that's the GGUF model file. |
| 104 | + var modelDigest string |
| 105 | + var modelSize int64 |
| 106 | + var mmprojDigest string |
| 107 | + for _, l := range mf.Layers { |
| 108 | + if strings.Contains(l.MediaType, "model") && l.Size > modelSize { |
| 109 | + modelDigest = l.Digest |
| 110 | + modelSize = l.Size |
| 111 | + } |
| 112 | + if strings.Contains(l.MediaType, "projector") || strings.Contains(l.MediaType, "mmproj") { |
| 113 | + mmprojDigest = l.Digest |
| 114 | + } |
| 115 | + } |
| 116 | + if modelDigest == "" { |
| 117 | + res.Skipped = append(res.Skipped, OllamaImportItem{Model: name + ":" + tag, Reason: "no model layer"}) |
| 118 | + return nil |
| 119 | + } |
| 120 | + |
| 121 | + blobName := strings.ReplaceAll(modelDigest, ":", "-") |
| 122 | + blobPath := filepath.Join(blobsDir, blobName) |
| 123 | + if _, err := os.Stat(blobPath); err != nil { |
| 124 | + res.Skipped = append(res.Skipped, OllamaImportItem{Model: name + ":" + tag, Reason: "blob missing: " + blobName}) |
| 125 | + return nil |
| 126 | + } |
| 127 | + |
| 128 | + ref := &ModelRef{Type: "llm", Name: name, Tag: tag} |
| 129 | + if existing, _ := store.Resolve(ref); existing != nil { |
| 130 | + res.Skipped = append(res.Skipped, OllamaImportItem{Model: name + ":" + tag, Reason: "already installed"}) |
| 131 | + return nil |
| 132 | + } |
| 133 | + |
| 134 | + item := OllamaImportItem{ |
| 135 | + Model: fmt.Sprintf("llm/%s:%s", name, tag), |
| 136 | + Size: modelSize, |
| 137 | + Path: blobPath, |
| 138 | + } |
| 139 | + if dryRun { |
| 140 | + res.Imported = append(res.Imported, item) |
| 141 | + return nil |
| 142 | + } |
| 143 | + |
| 144 | + m := &Model{ |
| 145 | + Type: "llm", |
| 146 | + Name: name, |
| 147 | + Tag: tag, |
| 148 | + Format: "gguf", |
| 149 | + SizeBytes: modelSize, |
| 150 | + LocalPath: blobPath, |
| 151 | + Source: "ollama-import:" + root, |
| 152 | + Capabilities: []string{"chat", "completion"}, |
| 153 | + DownloadedAt: time.Now(), |
| 154 | + } |
| 155 | + if mmprojDigest != "" { |
| 156 | + mmName := strings.ReplaceAll(mmprojDigest, ":", "-") |
| 157 | + mmPath := filepath.Join(blobsDir, mmName) |
| 158 | + if _, err := os.Stat(mmPath); err == nil { |
| 159 | + m.MmProjPath = mmPath |
| 160 | + m.Capabilities = append(m.Capabilities, "vision") |
| 161 | + } |
| 162 | + } |
| 163 | + if err := store.Save(m); err != nil { |
| 164 | + res.Skipped = append(res.Skipped, OllamaImportItem{Model: name + ":" + tag, Reason: "save failed: " + err.Error()}) |
| 165 | + return nil |
| 166 | + } |
| 167 | + res.Imported = append(res.Imported, item) |
| 168 | + return nil |
| 169 | + }) |
| 170 | + |
| 171 | + res.Count = len(res.Imported) |
| 172 | + return res, nil |
| 173 | +} |
0 commit comments