From a09f93889c24b432d60986cf98312d5ff136ba9b Mon Sep 17 00:00:00 2001 From: Doug Coleman Date: Thu, 30 Jan 2025 12:15:23 -0600 Subject: [PATCH 1/5] main: add reverse linking (LM Studio -> ollama) Fixes #68 cursor/claude-3.5-sonnet did all the coding --- main.go | 235 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 201 insertions(+), 34 deletions(-) diff --git a/main.go b/main.go index 5b5cabf..c703821 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,9 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" + "runtime" "sort" "strings" @@ -29,35 +31,35 @@ import ( ) type AppModel struct { - width int - height int - ollamaModelsDir string - cfg *config.Config - inspectedModel Model - list list.Model - models []Model - selectedModels []Model - confirmDeletion bool - inspecting bool - editing bool - message string - keys KeyMap - client *api.Client - lmStudioModelsDir string - noCleanup bool - table table.Model - filterInput tea.Model - showTop bool - progress progress.Model - altScreenActive bool - view View - showProgress bool - pullInput textinput.Model - pulling bool - pullProgress float64 - newModelPull bool - comparingModelfile bool - modelfileDiffs []ModelfileDiff + width int + height int + ollamaModelsDir string + cfg *config.Config + inspectedModel Model + list list.Model + models []Model + selectedModels []Model + confirmDeletion bool + inspecting bool + editing bool + message string + keys KeyMap + client *api.Client + lmStudioModelsDir string + noCleanup bool + table table.Model + filterInput tea.Model + showTop bool + progress progress.Model + altScreenActive bool + view View + showProgress bool + pullInput textinput.Model + pulling bool + pullProgress float64 + newModelPull bool + comparingModelfile bool + modelfileDiffs []ModelfileDiff } // TODO: Refactor: we don't need unique message types for every single action @@ -93,6 +95,134 @@ type View int var fitsVRAM float64 var Version string // Version is set by the build system +type LMStudioModel struct { + Name string + Path string + FileType string // e.g., "gguf", "bin", etc. +} + +func scanLMStudioModels(dirPath string) ([]LMStudioModel, error) { + var models []LMStudioModel + + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Check for model file extensions + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".gguf" || ext == ".bin" { + name := strings.TrimSuffix(filepath.Base(path), ext) + models = append(models, LMStudioModel{ + Name: name, + Path: path, + FileType: strings.TrimPrefix(ext, "."), + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error scanning directory: %w", err) + } + + return models, nil +} + +func getOllamaModelDir() string { + // Ollama's default model directory locations + homeDir := utils.GetHomeDir() + if runtime.GOOS == "darwin" { + return filepath.Join(homeDir, ".ollama", "models") + } else if runtime.GOOS == "linux" { + return "/usr/share/ollama/models" + } + // Add Windows path if needed + return filepath.Join(homeDir, ".ollama", "models") +} + +func modelExists(modelName string) bool { + cmd := exec.Command("ollama", "list") + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(output), modelName) +} + +func createModelfile(modelName string, modelPath string) error { + modelfilePath := filepath.Join(filepath.Dir(modelPath), fmt.Sprintf("Modelfile.%s", modelName)) + + // Check if Modelfile already exists + if _, err := os.Stat(modelfilePath); err == nil { + return nil + } + + modelfileContent := fmt.Sprintf(`FROM %s +PARAMETER temperature 0.7 +PARAMETER top_k 40 +PARAMETER top_p 0.4 +PARAMETER repeat_penalty 1.1 +PARAMETER repeat_last_n 64 +PARAMETER seed 0 +PARAMETER stop "Human:" "Assistant:" +TEMPLATE """ +{{.Prompt}} +Assistant: """ +SYSTEM """You are a helpful AI assistant.""" +`, filepath.Base(modelPath)) + + return os.WriteFile(modelfilePath, []byte(modelfileContent), 0644) +} + +func linkModelToOllama(model LMStudioModel) error { + ollamaDir := getOllamaModelDir() + + // Create Ollama models directory if it doesn't exist + if err := os.MkdirAll(ollamaDir, 0755); err != nil { + return fmt.Errorf("failed to create Ollama models directory: %w", err) + } + + targetPath := filepath.Join(ollamaDir, filepath.Base(model.Path)) + + // Create symlink for model file + if err := os.Symlink(model.Path, targetPath); err != nil { + if !os.IsExist(err) { + return fmt.Errorf("failed to create symlink: %w", err) + } + } + + // Check if model is already registered with Ollama + if modelExists(model.Name) { + return nil + } + + // Create model-specific Modelfile + modelfilePath := filepath.Join(filepath.Dir(targetPath), fmt.Sprintf("Modelfile.%s", model.Name)) + if err := createModelfile(model.Name, targetPath); err != nil { + return fmt.Errorf("failed to create Modelfile: %w", err) + } + + cmd := exec.Command("ollama", "create", model.Name, "-f", modelfilePath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create Ollama model: %s\n%w", string(output), err) + } + + // Clean up the Modelfile after successful creation + if err := os.Remove(modelfilePath); err != nil { + logging.ErrorLogger.Printf("Warning: Could not remove temporary Modelfile %s: %v\n", modelfilePath, err) + } + + return nil +} + func main() { if Version == "" { Version = "1.28.0" @@ -111,7 +241,8 @@ func main() { } listFlag := flag.Bool("l", false, "List all available Ollama models and exit") - linkFlag := flag.Bool("L", false, "Link a model to a specific name") + linkFlag := flag.Bool("L", false, "Link Ollama models to LM Studio (default: false)") + linkLMStudioFlag := flag.Bool("link-lmstudio", false, "Link LM Studio models to Ollama") ollamaDirFlag := flag.String("ollama-dir", cfg.OllamaAPIKey, "Custom Ollama models directory") lmStudioDirFlag := flag.String("lm-dir", cfg.LMStudioFilePaths, "Custom LM Studio models directory") noCleanupFlag := flag.Bool("no-cleanup", false, "Don't cleanup broken symlinks") @@ -344,12 +475,14 @@ func main() { fmt.Println("Error: Linking models is only supported on localhost") os.Exit(1) } + + // if cfg.LMStudioFilePaths is empty, use the default path in the user's home directory / .lmstudio / models + if cfg.LMStudioFilePaths == "" { + cfg.LMStudioFilePaths = filepath.Join(utils.GetHomeDir(), ".lmstudio", "models") + } + // link all models for _, model := range models { - // if cfg.LMStudioFilePaths is empty, use the default path in the user's home directory / .lmstudio / models - if cfg.LMStudioFilePaths == "" { - cfg.LMStudioFilePaths = filepath.Join(utils.GetHomeDir(), ".lmstudio", "models") - } message, err := linkModel(model.Name, cfg.LMStudioFilePaths, false, client) logging.InfoLogger.Println(message) fmt.Printf("Linking model %s to %s\n", model.Name, cfg.LMStudioFilePaths) @@ -365,6 +498,40 @@ func main() { os.Exit(0) } + if *linkLMStudioFlag { + if cfg.LMStudioFilePaths == "" { + cfg.LMStudioFilePaths = filepath.Join(utils.GetHomeDir(), ".lmstudio", "models") + } + + fmt.Printf("Scanning for LM Studio models in: %s\n", cfg.LMStudioFilePaths) + + models, err := scanLMStudioModels(cfg.LMStudioFilePaths) + if err != nil { + logging.ErrorLogger.Printf("Error scanning LM Studio models: %v\n", err) + fmt.Printf("Failed to scan LM Studio models directory: %v\n", err) + os.Exit(1) + } + + if len(models) == 0 { + fmt.Println("No LM Studio models found") + os.Exit(0) + } + + fmt.Printf("Found %d LM Studio models\n", len(models)) + + for _, model := range models { + fmt.Printf("Linking model %s... ", model.Name) + if err := linkModelToOllama(model); err != nil { + logging.ErrorLogger.Printf("Error linking model %s: %v\n", model.Name, err) + fmt.Printf("failed: %v\n", err) + continue + } + logging.InfoLogger.Printf("Model %s linked successfully\n", model.Name) + fmt.Println("success!") + } + os.Exit(0) + } + if *unloadModelsFlag { // get any loaded models client := app.client From 91413e8a0d0dfabcef8042039b37a7fe3da6d01e Mon Sep 17 00:00:00 2001 From: Doug Coleman Date: Thu, 30 Jan 2025 13:11:39 -0600 Subject: [PATCH 2/5] lmstudio: move lmstudio functions to a file to not pollute main --- lmstudio/link.go | 145 +++++++++++++++++++++++++++++++++++++++++++++++ main.go | 135 +------------------------------------------ 2 files changed, 148 insertions(+), 132 deletions(-) create mode 100644 lmstudio/link.go diff --git a/lmstudio/link.go b/lmstudio/link.go new file mode 100644 index 0000000..85c7a34 --- /dev/null +++ b/lmstudio/link.go @@ -0,0 +1,145 @@ +package lmstudio + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/sammcj/gollama/logging" + "github.com/sammcj/gollama/utils" +) + +type Model struct { + Name string + Path string + FileType string // e.g., "gguf", "bin", etc. +} + +// ScanModels scans the given directory for LM Studio model files +func ScanModels(dirPath string) ([]Model, error) { + var models []Model + + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Check for model file extensions + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".gguf" || ext == ".bin" { + name := strings.TrimSuffix(filepath.Base(path), ext) + models = append(models, Model{ + Name: name, + Path: path, + FileType: strings.TrimPrefix(ext, "."), + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error scanning directory: %w", err) + } + + return models, nil +} + +// GetOllamaModelDir returns the default Ollama models directory for the current OS +func GetOllamaModelDir() string { + homeDir := utils.GetHomeDir() + if runtime.GOOS == "darwin" { + return filepath.Join(homeDir, ".ollama", "models") + } else if runtime.GOOS == "linux" { + return "/usr/share/ollama/models" + } + // Add Windows path if needed + return filepath.Join(homeDir, ".ollama", "models") +} + +// modelExists checks if a model is already registered with Ollama +func modelExists(modelName string) bool { + cmd := exec.Command("ollama", "list") + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(output), modelName) +} + +// createModelfile creates a Modelfile for the given model +func createModelfile(modelName string, modelPath string) error { + modelfilePath := filepath.Join(filepath.Dir(modelPath), fmt.Sprintf("Modelfile.%s", modelName)) + + // Check if Modelfile already exists + if _, err := os.Stat(modelfilePath); err == nil { + return nil + } + + modelfileContent := fmt.Sprintf(`FROM %s +PARAMETER temperature 0.7 +PARAMETER top_k 40 +PARAMETER top_p 0.4 +PARAMETER repeat_penalty 1.1 +PARAMETER repeat_last_n 64 +PARAMETER seed 0 +PARAMETER stop "Human:" "Assistant:" +TEMPLATE """ +{{.Prompt}} +Assistant: """ +SYSTEM """You are a helpful AI assistant.""" +`, filepath.Base(modelPath)) + + return os.WriteFile(modelfilePath, []byte(modelfileContent), 0644) +} + +// LinkModelToOllama links an LM Studio model to Ollama +func LinkModelToOllama(model Model) error { + ollamaDir := GetOllamaModelDir() + + // Create Ollama models directory if it doesn't exist + if err := os.MkdirAll(ollamaDir, 0755); err != nil { + return fmt.Errorf("failed to create Ollama models directory: %w", err) + } + + targetPath := filepath.Join(ollamaDir, filepath.Base(model.Path)) + + // Create symlink for model file + if err := os.Symlink(model.Path, targetPath); err != nil { + if !os.IsExist(err) { + return fmt.Errorf("failed to create symlink: %w", err) + } + } + + // Check if model is already registered with Ollama + if modelExists(model.Name) { + return nil + } + + // Create model-specific Modelfile + modelfilePath := filepath.Join(filepath.Dir(targetPath), fmt.Sprintf("Modelfile.%s", model.Name)) + if err := createModelfile(model.Name, targetPath); err != nil { + return fmt.Errorf("failed to create Modelfile: %w", err) + } + + cmd := exec.Command("ollama", "create", model.Name, "-f", modelfilePath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create Ollama model: %s\n%w", string(output), err) + } + + // Clean up the Modelfile after successful creation + if err := os.Remove(modelfilePath); err != nil { + logging.ErrorLogger.Printf("Warning: Could not remove temporary Modelfile %s: %v\n", modelfilePath, err) + } + + return nil +} diff --git a/main.go b/main.go index c703821..2604364 100644 --- a/main.go +++ b/main.go @@ -8,9 +8,7 @@ import ( "net/http" "net/url" "os" - "os/exec" "path/filepath" - "runtime" "sort" "strings" @@ -25,6 +23,7 @@ import ( "golang.org/x/term" "github.com/sammcj/gollama/config" + "github.com/sammcj/gollama/lmstudio" "github.com/sammcj/gollama/logging" "github.com/sammcj/gollama/utils" "github.com/sammcj/gollama/vramestimator" @@ -95,134 +94,6 @@ type View int var fitsVRAM float64 var Version string // Version is set by the build system -type LMStudioModel struct { - Name string - Path string - FileType string // e.g., "gguf", "bin", etc. -} - -func scanLMStudioModels(dirPath string) ([]LMStudioModel, error) { - var models []LMStudioModel - - err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Skip directories - if info.IsDir() { - return nil - } - - // Check for model file extensions - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".gguf" || ext == ".bin" { - name := strings.TrimSuffix(filepath.Base(path), ext) - models = append(models, LMStudioModel{ - Name: name, - Path: path, - FileType: strings.TrimPrefix(ext, "."), - }) - } - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("error scanning directory: %w", err) - } - - return models, nil -} - -func getOllamaModelDir() string { - // Ollama's default model directory locations - homeDir := utils.GetHomeDir() - if runtime.GOOS == "darwin" { - return filepath.Join(homeDir, ".ollama", "models") - } else if runtime.GOOS == "linux" { - return "/usr/share/ollama/models" - } - // Add Windows path if needed - return filepath.Join(homeDir, ".ollama", "models") -} - -func modelExists(modelName string) bool { - cmd := exec.Command("ollama", "list") - output, err := cmd.CombinedOutput() - if err != nil { - return false - } - return strings.Contains(string(output), modelName) -} - -func createModelfile(modelName string, modelPath string) error { - modelfilePath := filepath.Join(filepath.Dir(modelPath), fmt.Sprintf("Modelfile.%s", modelName)) - - // Check if Modelfile already exists - if _, err := os.Stat(modelfilePath); err == nil { - return nil - } - - modelfileContent := fmt.Sprintf(`FROM %s -PARAMETER temperature 0.7 -PARAMETER top_k 40 -PARAMETER top_p 0.4 -PARAMETER repeat_penalty 1.1 -PARAMETER repeat_last_n 64 -PARAMETER seed 0 -PARAMETER stop "Human:" "Assistant:" -TEMPLATE """ -{{.Prompt}} -Assistant: """ -SYSTEM """You are a helpful AI assistant.""" -`, filepath.Base(modelPath)) - - return os.WriteFile(modelfilePath, []byte(modelfileContent), 0644) -} - -func linkModelToOllama(model LMStudioModel) error { - ollamaDir := getOllamaModelDir() - - // Create Ollama models directory if it doesn't exist - if err := os.MkdirAll(ollamaDir, 0755); err != nil { - return fmt.Errorf("failed to create Ollama models directory: %w", err) - } - - targetPath := filepath.Join(ollamaDir, filepath.Base(model.Path)) - - // Create symlink for model file - if err := os.Symlink(model.Path, targetPath); err != nil { - if !os.IsExist(err) { - return fmt.Errorf("failed to create symlink: %w", err) - } - } - - // Check if model is already registered with Ollama - if modelExists(model.Name) { - return nil - } - - // Create model-specific Modelfile - modelfilePath := filepath.Join(filepath.Dir(targetPath), fmt.Sprintf("Modelfile.%s", model.Name)) - if err := createModelfile(model.Name, targetPath); err != nil { - return fmt.Errorf("failed to create Modelfile: %w", err) - } - - cmd := exec.Command("ollama", "create", model.Name, "-f", modelfilePath) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create Ollama model: %s\n%w", string(output), err) - } - - // Clean up the Modelfile after successful creation - if err := os.Remove(modelfilePath); err != nil { - logging.ErrorLogger.Printf("Warning: Could not remove temporary Modelfile %s: %v\n", modelfilePath, err) - } - - return nil -} - func main() { if Version == "" { Version = "1.28.0" @@ -505,7 +376,7 @@ func main() { fmt.Printf("Scanning for LM Studio models in: %s\n", cfg.LMStudioFilePaths) - models, err := scanLMStudioModels(cfg.LMStudioFilePaths) + models, err := lmstudio.ScanModels(cfg.LMStudioFilePaths) if err != nil { logging.ErrorLogger.Printf("Error scanning LM Studio models: %v\n", err) fmt.Printf("Failed to scan LM Studio models directory: %v\n", err) @@ -521,7 +392,7 @@ func main() { for _, model := range models { fmt.Printf("Linking model %s... ", model.Name) - if err := linkModelToOllama(model); err != nil { + if err := lmstudio.LinkModelToOllama(model); err != nil { logging.ErrorLogger.Printf("Error linking model %s: %v\n", model.Name, err) fmt.Printf("failed: %v\n", err) continue From a1692536c2c8110fb5c8581850ecb28521a42d4d Mon Sep 17 00:00:00 2001 From: Doug Coleman Date: Thu, 30 Jan 2025 14:12:58 -0600 Subject: [PATCH 3/5] lmstudio: make changes re: the llamapreview bot --- lmstudio/link.go | 106 ++++++++++++++++++++++++++++++++++++----------- main.go | 8 ++++ 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/lmstudio/link.go b/lmstudio/link.go index 85c7a34..7dd305a 100644 --- a/lmstudio/link.go +++ b/lmstudio/link.go @@ -7,6 +7,7 @@ import ( "path/filepath" "runtime" "strings" + "text/template" "github.com/sammcj/gollama/logging" "github.com/sammcj/gollama/utils" @@ -18,13 +19,40 @@ type Model struct { FileType string // e.g., "gguf", "bin", etc. } +// ModelfileTemplate contains the default template for creating Modelfiles +const ModelfileTemplate = `FROM {{.ModelPath}} +PARAMETER temperature 0.7 +PARAMETER top_k 40 +PARAMETER top_p 0.4 +PARAMETER repeat_penalty 1.1 +PARAMETER repeat_last_n 64 +PARAMETER seed 0 +PARAMETER stop "Human:" "Assistant:" +TEMPLATE """ +{{.Prompt}} +Assistant: """ +SYSTEM """You are a helpful AI assistant.""" +` + +type ModelfileData struct { + ModelPath string + Prompt string +} + // ScanModels scans the given directory for LM Studio model files func ScanModels(dirPath string) ([]Model, error) { var models []Model - err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + // First check if directory exists + if _, err := os.Stat(dirPath); os.IsNotExist(err) { + return nil, fmt.Errorf("LM Studio models directory does not exist: %s", dirPath) + } + + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, walkErr error) error { + // Handle walk errors immediately + if walkErr != nil { + logging.ErrorLogger.Printf("Error accessing path %s: %v", path, walkErr) + return walkErr } // Skip directories @@ -36,18 +64,34 @@ func ScanModels(dirPath string) ([]Model, error) { ext := strings.ToLower(filepath.Ext(path)) if ext == ".gguf" || ext == ".bin" { name := strings.TrimSuffix(filepath.Base(path), ext) - models = append(models, Model{ + + // Basic name validation + if strings.ContainsAny(name, "/\\:*?\"<>|") { + logging.ErrorLogger.Printf("Skipping model with invalid characters in name: %s", name) + return nil + } + + model := Model{ Name: name, Path: path, FileType: strings.TrimPrefix(ext, "."), - }) + } + + logging.DebugLogger.Printf("Found model: %s (%s)", model.Name, model.FileType) + models = append(models, model) } return nil }) if err != nil { - return nil, fmt.Errorf("error scanning directory: %w", err) + return nil, fmt.Errorf("error scanning directory %s: %w", dirPath, err) + } + + if len(models) == 0 { + logging.InfoLogger.Printf("No models found in directory: %s", dirPath) + } else { + logging.InfoLogger.Printf("Found %d models in directory: %s", len(models), dirPath) } return models, nil @@ -81,24 +125,31 @@ func createModelfile(modelName string, modelPath string) error { // Check if Modelfile already exists if _, err := os.Stat(modelfilePath); err == nil { + logging.InfoLogger.Printf("Modelfile already exists for %s, skipping creation", modelName) return nil } - modelfileContent := fmt.Sprintf(`FROM %s -PARAMETER temperature 0.7 -PARAMETER top_k 40 -PARAMETER top_p 0.4 -PARAMETER repeat_penalty 1.1 -PARAMETER repeat_last_n 64 -PARAMETER seed 0 -PARAMETER stop "Human:" "Assistant:" -TEMPLATE """ -{{.Prompt}} -Assistant: """ -SYSTEM """You are a helpful AI assistant.""" -`, filepath.Base(modelPath)) + tmpl, err := template.New("modelfile").Parse(ModelfileTemplate) + if err != nil { + return fmt.Errorf("failed to parse Modelfile template: %w", err) + } + + data := ModelfileData{ + ModelPath: filepath.Base(modelPath), + Prompt: "{{.Prompt}}", // Preserve this as a template variable for Ollama + } - return os.WriteFile(modelfilePath, []byte(modelfileContent), 0644) + file, err := os.OpenFile(modelfilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create Modelfile: %w", err) + } + defer file.Close() + + if err := tmpl.Execute(file, data); err != nil { + return fmt.Errorf("failed to write Modelfile template: %w", err) + } + + return nil } // LinkModelToOllama links an LM Studio model to Ollama @@ -114,8 +165,10 @@ func LinkModelToOllama(model Model) error { // Create symlink for model file if err := os.Symlink(model.Path, targetPath); err != nil { - if !os.IsExist(err) { - return fmt.Errorf("failed to create symlink: %w", err) + if os.IsExist(err) { + logging.InfoLogger.Printf("Symlink already exists for %s at %s", model.Name, targetPath) + } else { + return fmt.Errorf("failed to create symlink for %s to %s: %w", model.Name, targetPath, err) } } @@ -127,18 +180,21 @@ func LinkModelToOllama(model Model) error { // Create model-specific Modelfile modelfilePath := filepath.Join(filepath.Dir(targetPath), fmt.Sprintf("Modelfile.%s", model.Name)) if err := createModelfile(model.Name, targetPath); err != nil { - return fmt.Errorf("failed to create Modelfile: %w", err) + return fmt.Errorf("failed to create Modelfile for %s: %w", model.Name, err) } + // Create the model in Ollama cmd := exec.Command("ollama", "create", model.Name, "-f", modelfilePath) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to create Ollama model: %s\n%w", string(output), err) + // Clean up Modelfile on failure + os.Remove(modelfilePath) + return fmt.Errorf("failed to create Ollama model %s: %s - %w", model.Name, string(output), err) } // Clean up the Modelfile after successful creation if err := os.Remove(modelfilePath); err != nil { - logging.ErrorLogger.Printf("Warning: Could not remove temporary Modelfile %s: %v\n", modelfilePath, err) + logging.ErrorLogger.Printf("Warning: Could not remove temporary Modelfile %s: %v", modelfilePath, err) } return nil diff --git a/main.go b/main.go index 2604364..f61ded6 100644 --- a/main.go +++ b/main.go @@ -389,16 +389,24 @@ func main() { } fmt.Printf("Found %d LM Studio models\n", len(models)) + var successCount, failCount int for _, model := range models { fmt.Printf("Linking model %s... ", model.Name) if err := lmstudio.LinkModelToOllama(model); err != nil { logging.ErrorLogger.Printf("Error linking model %s: %v\n", model.Name, err) fmt.Printf("failed: %v\n", err) + failCount++ continue } logging.InfoLogger.Printf("Model %s linked successfully\n", model.Name) fmt.Println("success!") + successCount++ + } + + fmt.Printf("\nSummary: %d models linked successfully, %d failed\n", successCount, failCount) + if failCount > 0 { + os.Exit(1) } os.Exit(0) } From 6bd0321fd3890943da718f0cf73f843327016af8 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 31 Jan 2025 09:12:09 +1100 Subject: [PATCH 4/5] feat(lm-to-ollama): update readme --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 16119f6..cedb8b9 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,17 @@ Inspect (`i`) #### Link -Link (`l`) and Link All (`L`) +Link (`l`), Link All (`L`) and Link in the reverse direction: (`link-lmstudio`) -Note: Requires Admin privileges if you're running Windows. +When linking models to LM Studio, Gollama creates a Modelfile with default parameters and template that you can adjust. + +Note: Linking requires admin privileges if you're running Windows. #### Command-line Options - `-l`: List all available Ollama models and exit - `-L`: Link all available Ollama models to LM Studio and exit +- `-link-lmstudio`: Link all available LM Studio models to Ollama and exit - `-s `: Search for models by name - OR operator (`'term1|term2'`) returns models that match either term - AND operator (`'term1&term2'`) returns models that match both terms From 1d4ce357fd697b05c87688b72852dbfe6cef1f4d Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 31 Jan 2025 09:28:06 +1100 Subject: [PATCH 5/5] feat(lm-to-ollama): update template feat(lm-to-ollama): add -dry-run to linking feat(lm-to-ollama): add recursive linking protection --- README.md | 3 +- app_model.go | 4 +-- lmstudio/link.go | 87 ++++++++++++++++++++++++++++++++++++------------ main.go | 37 ++++++++++++++------ operations.go | 44 +++++++++++++----------- utils/utils.go | 6 ++++ 6 files changed, 127 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index cedb8b9..f4eb594 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Inspect (`i`) Link (`l`), Link All (`L`) and Link in the reverse direction: (`link-lmstudio`) -When linking models to LM Studio, Gollama creates a Modelfile with default parameters and template that you can adjust. +When linking models to LM Studio, Gollama creates a Modelfile with the template from LM-Studio and a set of default parameters that you can adjust. Note: Linking requires admin privileges if you're running Windows. @@ -152,6 +152,7 @@ Note: Linking requires admin privileges if you're running Windows. - `-l`: List all available Ollama models and exit - `-L`: Link all available Ollama models to LM Studio and exit - `-link-lmstudio`: Link all available LM Studio models to Ollama and exit +- `--dry-run`: Show what would be linked without making any changes (use with -link-lmstudio or -L) - `-s `: Search for models by name - OR operator (`'term1|term2'`) returns models that match either term - AND operator (`'term1&term2'`) returns models that match both terms diff --git a/app_model.go b/app_model.go index ce6b8ac..2f765b4 100644 --- a/app_model.go +++ b/app_model.go @@ -594,7 +594,7 @@ func (m *AppModel) handleLinkModelKey() (tea.Model, tea.Cmd) { return m, nil } if item, ok := m.list.SelectedItem().(Model); ok { - message, err := linkModel(item.Name, m.lmStudioModelsDir, m.noCleanup, m.client) + message, err := linkModel(item.Name, m.lmStudioModelsDir, m.noCleanup, false, m.client) if err != nil { m.message = fmt.Sprintf("Error linking model: %v", err) } else if message != "" { @@ -615,7 +615,7 @@ func (m *AppModel) handleLinkAllModelsKey() (tea.Model, tea.Cmd) { } var messages []string for _, model := range m.models { - message, err := linkModel(model.Name, m.lmStudioModelsDir, m.noCleanup, m.client) + message, err := linkModel(model.Name, m.lmStudioModelsDir, m.noCleanup, false, m.client) if err != nil { messages = append(messages, fmt.Sprintf("Error linking model %s: %v", model.Name, err)) } else if message != "" { diff --git a/lmstudio/link.go b/lmstudio/link.go index 7dd305a..b631569 100644 --- a/lmstudio/link.go +++ b/lmstudio/link.go @@ -20,23 +20,37 @@ type Model struct { } // ModelfileTemplate contains the default template for creating Modelfiles -const ModelfileTemplate = `FROM {{.ModelPath}} -PARAMETER temperature 0.7 -PARAMETER top_k 40 -PARAMETER top_p 0.4 -PARAMETER repeat_penalty 1.1 -PARAMETER repeat_last_n 64 -PARAMETER seed 0 -PARAMETER stop "Human:" "Assistant:" +// TODO: Make the default Modelfile template configurable +const ModelfileTemplate = `### MODEL IMPORTED FROM LM-STUDIO BY GOLLAMA ### + +# Tune the below inference, model load parameters and template to your needs +# The template and stop parameters are currently set to the default for models that use the ChatML format +# If required update these match the prompt format your model expects +# You can look at existing similar models on the Ollama model hub for examples +# See https://github.com/ollama/ollama/blob/main/docs/modelfile.md for a complete reference + +FROM {{.ModelPath}} + +### Model Load Parameters ### +PARAMETER num_ctx 4096 + +### Inference Parameters #### +PARAMETER temperature 0.4 +PARAMETER top_p 0.6 + +### Chat Template Parameters ### + TEMPLATE """ {{.Prompt}} -Assistant: """ -SYSTEM """You are a helpful AI assistant.""" +""" + +PARAMETER stop "<|im_start|>" +PARAMETER stop "<|im_end|>" ` type ModelfileData struct { ModelPath string - Prompt string + Prompt string } // ScanModels scans the given directory for LM Studio model files @@ -55,8 +69,11 @@ func ScanModels(dirPath string) ([]Model, error) { return walkErr } - // Skip directories - if info.IsDir() { + // Skip directories and symlinks + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + if info.Mode()&os.ModeSymlink != 0 { + logging.DebugLogger.Printf("Skipping symlinked model: %s\n", path) + } return nil } @@ -135,8 +152,8 @@ func createModelfile(modelName string, modelPath string) error { } data := ModelfileData{ - ModelPath: filepath.Base(modelPath), - Prompt: "{{.Prompt}}", // Preserve this as a template variable for Ollama + ModelPath: modelPath, // Use full path instead of just the base name + Prompt: "{{.Prompt}}", // Preserve this as a template variable for Ollama } file, err := os.OpenFile(modelfilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) @@ -145,26 +162,44 @@ func createModelfile(modelName string, modelPath string) error { } defer file.Close() + logging.DebugLogger.Printf("Creating Modelfile at: %s with model path: %s", modelfilePath, data.ModelPath) + if err := tmpl.Execute(file, data); err != nil { return fmt.Errorf("failed to write Modelfile template: %w", err) } + // Log the content of the created Modelfile for debugging + content, err := os.ReadFile(modelfilePath) + if err != nil { + logging.ErrorLogger.Printf("Warning: Could not read back Modelfile for verification: %v", err) + } else { + logging.DebugLogger.Printf("Created Modelfile content:\n%s", string(content)) + } + return nil } // LinkModelToOllama links an LM Studio model to Ollama -func LinkModelToOllama(model Model) error { +// If dryRun is true, it will only print what would happen without making any changes +func LinkModelToOllama(model Model, dryRun bool, ollamaHost string) error { + // Check if we're connecting to a local Ollama instance + if !utils.IsLocalhost(ollamaHost) { + return fmt.Errorf("linking LM Studio models to Ollama is only supported when connecting to a local Ollama instance (got %s)", ollamaHost) + } + ollamaDir := GetOllamaModelDir() - // Create Ollama models directory if it doesn't exist - if err := os.MkdirAll(ollamaDir, 0755); err != nil { + if dryRun { + logging.InfoLogger.Printf("[DRY RUN] Would create Ollama models directory at: %s", ollamaDir) + } else if err := os.MkdirAll(ollamaDir, 0755); err != nil { return fmt.Errorf("failed to create Ollama models directory: %w", err) } targetPath := filepath.Join(ollamaDir, filepath.Base(model.Path)) - // Create symlink for model file - if err := os.Symlink(model.Path, targetPath); err != nil { + if dryRun { + logging.InfoLogger.Printf("[DRY RUN] Would create symlink from %s to %s", model.Path, targetPath) + } else if err := os.Symlink(model.Path, targetPath); err != nil { if os.IsExist(err) { logging.InfoLogger.Printf("Symlink already exists for %s at %s", model.Name, targetPath) } else { @@ -173,24 +208,34 @@ func LinkModelToOllama(model Model) error { } // Check if model is already registered with Ollama - if modelExists(model.Name) { + if !dryRun && modelExists(model.Name) { return nil } // Create model-specific Modelfile modelfilePath := filepath.Join(filepath.Dir(targetPath), fmt.Sprintf("Modelfile.%s", model.Name)) + if dryRun { + logging.InfoLogger.Printf("[DRY RUN] Would create Modelfile at: %s", modelfilePath) + logging.InfoLogger.Printf("[DRY RUN] Would create Ollama model: %s using Modelfile", model.Name) + return nil + } + if err := createModelfile(model.Name, targetPath); err != nil { return fmt.Errorf("failed to create Modelfile for %s: %w", model.Name, err) } // Create the model in Ollama + logging.DebugLogger.Printf("Creating Ollama model %s using Modelfile at: %s", model.Name, modelfilePath) cmd := exec.Command("ollama", "create", model.Name, "-f", modelfilePath) output, err := cmd.CombinedOutput() if err != nil { + // Log the error output for debugging + logging.ErrorLogger.Printf("Ollama create command output: %s", string(output)) // Clean up Modelfile on failure os.Remove(modelfilePath) return fmt.Errorf("failed to create Ollama model %s: %s - %w", model.Name, string(output), err) } + logging.DebugLogger.Printf("Successfully created Ollama model %s", model.Name) // Clean up the Modelfile after successful creation if err := os.Remove(modelfilePath); err != nil { diff --git a/main.go b/main.go index f61ded6..d24c9f1 100644 --- a/main.go +++ b/main.go @@ -112,8 +112,9 @@ func main() { } listFlag := flag.Bool("l", false, "List all available Ollama models and exit") - linkFlag := flag.Bool("L", false, "Link Ollama models to LM Studio (default: false)") + linkFlag := flag.Bool("L", false, "Link Ollama models to LM Studio") linkLMStudioFlag := flag.Bool("link-lmstudio", false, "Link LM Studio models to Ollama") + dryRunFlag := flag.Bool("dry-run", false, "Show what would be linked without making any changes (use with -L or -link-lmstudio)") ollamaDirFlag := flag.String("ollama-dir", cfg.OllamaAPIKey, "Custom Ollama models directory") lmStudioDirFlag := flag.String("lm-dir", cfg.LMStudioFilePaths, "Custom LM Studio models directory") noCleanupFlag := flag.Bool("no-cleanup", false, "Don't cleanup broken symlinks") @@ -352,18 +353,24 @@ func main() { cfg.LMStudioFilePaths = filepath.Join(utils.GetHomeDir(), ".lmstudio", "models") } + prefix := "" + if *dryRunFlag { + prefix = "[DRY RUN] " + fmt.Printf("%sWould link Ollama models to LM Studio\n", prefix) + } + // link all models for _, model := range models { - message, err := linkModel(model.Name, cfg.LMStudioFilePaths, false, client) - logging.InfoLogger.Println(message) - fmt.Printf("Linking model %s to %s\n", model.Name, cfg.LMStudioFilePaths) + message, err := linkModel(model.Name, cfg.LMStudioFilePaths, false, *dryRunFlag, client) + if message != "" { + logging.InfoLogger.Println(message) + fmt.Printf("%s%s\n", prefix, message) + } if err != nil { logging.ErrorLogger.Printf("Error linking model %s: %v\n", model.Name, err) - fmt.Println("Error: Linking models failed. Please check if you are running without Administrator on Windows.") + fmt.Printf("Error: Linking models failed. Please check if you are running without Administrator on Windows.\n") fmt.Printf("Error detail: %v\n", err) os.Exit(1) - } else { - logging.InfoLogger.Printf("Model %s linked\n", model.Name) } } os.Exit(0) @@ -388,12 +395,16 @@ func main() { os.Exit(0) } - fmt.Printf("Found %d LM Studio models\n", len(models)) + prefix := "" + if *dryRunFlag { + prefix = "[DRY RUN] " + } + fmt.Printf("%sFound %d LM Studio models\n", prefix, len(models)) var successCount, failCount int for _, model := range models { - fmt.Printf("Linking model %s... ", model.Name) - if err := lmstudio.LinkModelToOllama(model); err != nil { + fmt.Printf("%sProcessing model %s... ", prefix, model.Name) + if err := lmstudio.LinkModelToOllama(model, *dryRunFlag, cfg.OllamaAPIURL); err != nil { logging.ErrorLogger.Printf("Error linking model %s: %v\n", model.Name, err) fmt.Printf("failed: %v\n", err) failCount++ @@ -404,7 +415,11 @@ func main() { successCount++ } - fmt.Printf("\nSummary: %d models linked successfully, %d failed\n", successCount, failCount) + if *dryRunFlag { + fmt.Printf("\n[DRY RUN] Summary: Would link %d models, %d would fail\n", successCount, failCount) + } else { + fmt.Printf("\nSummary: %d models linked successfully, %d failed\n", successCount, failCount) + } if failCount > 0 { os.Exit(1) } diff --git a/operations.go b/operations.go index 277aebe..d566e80 100644 --- a/operations.go +++ b/operations.go @@ -177,7 +177,7 @@ func (m *AppModel) pullModelCmd(modelName string) tea.Cmd { } } -func linkModel(modelName, lmStudioModelsDir string, noCleanup bool, client *api.Client) (string, error) { +func linkModel(modelName, lmStudioModelsDir string, noCleanup bool, dryRun bool, client *api.Client) (string, error) { modelPath, err := getModelPath(modelName, client) if err != nil { return "", fmt.Errorf("error getting model path for %s: %v", modelName, err) @@ -250,25 +250,31 @@ func linkModel(modelName, lmStudioModelsDir string, noCleanup bool, client *api. return fmt.Sprintf("Removed duplicated model directory %s", lmStudioModelDir), nil } - // Create the symlink - err = os.MkdirAll(lmStudioModelDir, os.ModePerm) - if err != nil { - message := "failed to create directory %s: %v" - logging.ErrorLogger.Printf(message+"\n", lmStudioModelDir, err) - return "", fmt.Errorf(message, lmStudioModelDir, err) - } - err = os.Symlink(modelPath, lmStudioModelPath) - if err != nil { - message := "failed to symlink %s: %v" - logging.ErrorLogger.Printf(message+"\n", modelName, err) - return "", fmt.Errorf(message, modelName, err) - } - if !noCleanup { - cleanBrokenSymlinks(lmStudioModelsDir) + if dryRun { + message := "[DRY RUN] Would create directory %s and symlink %s to %s" + logging.InfoLogger.Printf(message+"\n", lmStudioModelDir, modelName, lmStudioModelPath) + return fmt.Sprintf(message, lmStudioModelDir, modelName, lmStudioModelPath), nil + } else { + // Create the symlink + err = os.MkdirAll(lmStudioModelDir, os.ModePerm) + if err != nil { + message := "failed to create directory %s: %v" + logging.ErrorLogger.Printf(message+"\n", lmStudioModelDir, err) + return "", fmt.Errorf(message, lmStudioModelDir, err) + } + err = os.Symlink(modelPath, lmStudioModelPath) + if err != nil { + message := "failed to symlink %s: %v" + logging.ErrorLogger.Printf(message+"\n", modelName, err) + return "", fmt.Errorf(message, modelName, err) + } + if !noCleanup { + cleanBrokenSymlinks(lmStudioModelsDir) + } + message := "Symlinked %s to %s" + logging.InfoLogger.Printf(message+"\n", modelName, lmStudioModelPath) + return "", nil } - message := "Symlinked %s to %s" - logging.InfoLogger.Printf(message+"\n", modelName, lmStudioModelPath) - return "", nil } func getModelPath(modelName string, client *api.Client) (string, error) { diff --git a/utils/utils.go b/utils/utils.go index c303079..045c9a5 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( "os" "path/filepath" + "strings" "github.com/sammcj/gollama/logging" ) @@ -26,3 +27,8 @@ func GetConfigDir() string { func GetConfigPath() string { return filepath.Join(GetHomeDir(), ".config", "gollama", "config.json") } + +// IsLocalhost checks if a URL or host string refers to localhost +func IsLocalhost(url string) bool { + return strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") +}