Skip to content

Commit a96fb1d

Browse files
metiu1claude
andcommitted
fix(import-ollama): import without a running server; release 0.3.80
`vortelio import-ollama` POSTed to the local server and failed with "connection refused" when it wasn't running — despite a comment promising a server-less fallback that never existed. Move the scan/register logic into hub.ImportOllama and call it directly from the CLI; the HTTP handler now wraps the same function. Verified: dry-run finds local Ollama models with no server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f7f8708 commit a96fb1d

8 files changed

Lines changed: 195 additions & 200 deletions

File tree

vortelio-pip/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "vortelio"
7-
version = "0.3.79"
7+
version = "0.3.80"
88
description = "Local-first AI platform. Run LLMs, generate images & video, transcribe audio, create 3D — on your own machine. OpenAI & Ollama API compatible. Apache 2.0."
99
readme = "README.md"
1010
requires-python = ">=3.8"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.79"
1+
__version__ = "0.3.80"

vortelio-pip/src/vortelio_cli/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import urllib.request
1111
from pathlib import Path
1212

13-
VERSION = "0.3.79"
13+
VERSION = "0.3.80"
1414
RELEASE_BASE = os.environ.get(
1515
"VORTELIO_RELEASE_BASE",
1616
f"https://github.com/metiu1/Vortelio/releases/download/v{VERSION}",
-1.5 KB
Binary file not shown.
Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
package commands
22

33
import (
4-
"bytes"
5-
"encoding/json"
64
"fmt"
7-
"io"
8-
"net/http"
9-
"os"
10-
"path/filepath"
11-
"time"
125

13-
"github.com/vortelio/vortelio/internal/config"
6+
"github.com/vortelio/vortelio/internal/hub"
147
)
158

169
type ImportOllamaCommand struct{}
@@ -32,62 +25,31 @@ func (c *ImportOllamaCommand) Run(args []string) error {
3225
}
3326
}
3427

35-
// Try direct local path first (no server needed)
3628
if customPath == "" {
37-
customPath = defaultOllamaPath()
29+
customPath = hub.OllamaDefaultDir()
3830
}
3931
fmt.Printf("📦 Importing Ollama models from: %s\n", customPath)
4032
if dryRun {
4133
fmt.Println(" (dry run — no changes)")
4234
}
4335

44-
// Try the running server first; fall back to direct stdin approach
45-
port := fmt.Sprintf("%d", config.Get().Port)
46-
body, _ := json.Marshal(map[string]interface{}{
47-
"ollama_path": customPath,
48-
"dry_run": dryRun,
49-
})
50-
resp, err := http.Post("http://127.0.0.1:"+port+"/api/import/ollama", "application/json", bytes.NewReader(body))
36+
// Import directly — no running server needed. The models are registered in
37+
// the same store the server uses, so they show up in the GUI immediately.
38+
res, err := hub.ImportOllama(customPath, dryRun)
5139
if err != nil {
52-
return fmt.Errorf("server not running — start 'vortelio serve' first: %w", err)
40+
return err
5341
}
54-
defer resp.Body.Close()
55-
data, _ := io.ReadAll(resp.Body)
5642

57-
if resp.StatusCode != 200 {
58-
var er map[string]interface{}
59-
json.Unmarshal(data, &er)
60-
return fmt.Errorf("server error %d: %v", resp.StatusCode, er["error"])
61-
}
62-
63-
var result struct {
64-
Imported []map[string]interface{} `json:"imported"`
65-
Skipped []map[string]interface{} `json:"skipped"`
66-
Count int `json:"count"`
67-
}
68-
json.Unmarshal(data, &result)
69-
70-
for _, it := range result.Imported {
43+
for _, it := range res.Imported {
7144
mark := "✅"
7245
if dryRun {
7346
mark = "🔎"
7447
}
75-
fmt.Printf(" %s %s\n", mark, it["model"])
48+
fmt.Printf(" %s %s\n", mark, it.Model)
7649
}
77-
for _, it := range result.Skipped {
78-
fmt.Printf(" ⏭ %s — %s\n", it["model"], it["reason"])
50+
for _, it := range res.Skipped {
51+
fmt.Printf(" ⏭ %s — %s\n", it.Model, it.Reason)
7952
}
80-
fmt.Printf("\n%d modelli importati.\n", result.Count)
53+
fmt.Printf("\n%d modelli importati.\n", res.Count)
8154
return nil
8255
}
83-
84-
func defaultOllamaPath() string {
85-
if v := os.Getenv("OLLAMA_MODELS"); v != "" {
86-
return filepath.Dir(filepath.Dir(v))
87-
}
88-
home, _ := os.UserHomeDir()
89-
return filepath.Join(home, ".ollama")
90-
}
91-
92-
// Touch time to avoid unused import on some platforms
93-
var _ = time.Now
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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

Comments
 (0)