mirror of
https://github.com/ollama/ollama.git
synced 2026-01-19 21:08:16 -05:00
Compare commits
9 Commits
parth-x-cm
...
parth/decr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2abfb433 | ||
|
|
805ed4644c | ||
|
|
e4b488a7b5 | ||
|
|
98079ddd79 | ||
|
|
d70942f47b | ||
|
|
58e4701557 | ||
|
|
dbf47ee55a | ||
|
|
af7ea6e96e | ||
|
|
8f1e0140e7 |
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@@ -372,13 +372,17 @@ jobs:
|
|||||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
- name: Deduplicate CUDA libraries
|
||||||
|
run: |
|
||||||
|
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||||
- run: |
|
- run: |
|
||||||
for COMPONENT in bin/* lib/ollama/*; do
|
for COMPONENT in bin/* lib/ollama/*; do
|
||||||
case "$COMPONENT" in
|
case "$COMPONENT" in
|
||||||
bin/ollama) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
|
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
|||||||
set(GGML_CPU_ALL_VARIANTS ON)
|
set(GGML_CPU_ALL_VARIANTS ON)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if (CMAKE_OSX_ARCHITECTURES MATCHES "x86_64")
|
if(APPLE)
|
||||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||||
|
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||||
@@ -196,6 +197,14 @@ if(MLX_ENGINE)
|
|||||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||||
|
# Metal backend is only built for arm64, not x86_64
|
||||||
|
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||||
|
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||||
|
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||||
|
COMPONENT MLX)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Manually install cudart and cublas since they might not be picked up as direct dependencies
|
# Manually install cudart and cublas since they might not be picked up as direct dependencies
|
||||||
if(CUDAToolkit_FOUND)
|
if(CUDAToolkit_FOUND)
|
||||||
file(GLOB CUDART_LIBS
|
file(GLOB CUDART_LIBS
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ ARG GOFLAGS="'-ldflags=-w -s'"
|
|||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
ARG CGO_CFLAGS
|
ARG CGO_CFLAGS
|
||||||
ARG CGO_CXXFLAGS
|
ARG CGO_CXXFLAGS
|
||||||
|
RUN mkdir -p dist/bin
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go build -tags mlx -trimpath -buildmode=pie -o dist/bin/ollama-mlx .
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
WORKDIR /go/src/github.com/ollama/ollama
|
WORKDIR /go/src/github.com/ollama/ollama
|
||||||
@@ -182,6 +185,7 @@ COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
|||||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||||
|
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/bin/ /bin/
|
||||||
|
|
||||||
FROM --platform=linux/arm64 scratch AS arm64
|
FROM --platform=linux/arm64 scratch AS arm64
|
||||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||||
|
|||||||
800
cmd/apps.go
800
cmd/apps.go
@@ -1,800 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EnvVar struct {
|
|
||||||
Name string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppConfig struct {
|
|
||||||
Name string
|
|
||||||
DisplayName string
|
|
||||||
Command string
|
|
||||||
EnvVars func(model string) []EnvVar
|
|
||||||
Args func(model string) []string
|
|
||||||
Setup func(models []string) error
|
|
||||||
CheckInstall func() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkCommand returns an error if the command is not installed
|
|
||||||
func checkCommand(cmd, installInstructions string) func() error {
|
|
||||||
return func() error {
|
|
||||||
if _, err := exec.LookPath(cmd); err != nil {
|
|
||||||
return fmt.Errorf("%s is not installed. %s", cmd, installInstructions)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ClaudeConfig = &AppConfig{
|
|
||||||
Name: "Claude",
|
|
||||||
DisplayName: "Claude Code",
|
|
||||||
Command: "claude",
|
|
||||||
EnvVars: func(model string) []EnvVar {
|
|
||||||
return []EnvVar{
|
|
||||||
{Name: "ANTHROPIC_BASE_URL", Value: "http://localhost:11434"},
|
|
||||||
{Name: "ANTHROPIC_API_KEY", Value: "ollama"},
|
|
||||||
{Name: "ANTHROPIC_AUTH_TOKEN", Value: "ollama"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Args: func(model string) []string {
|
|
||||||
if model == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{"--model", model}
|
|
||||||
},
|
|
||||||
CheckInstall: checkCommand("claude", "Install with: npm install -g @anthropic-ai/claude-code"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var CodexConfig = &AppConfig{
|
|
||||||
Name: "Codex",
|
|
||||||
DisplayName: "Codex",
|
|
||||||
Command: "codex",
|
|
||||||
EnvVars: func(model string) []EnvVar {
|
|
||||||
return []EnvVar{}
|
|
||||||
},
|
|
||||||
Args: func(model string) []string {
|
|
||||||
// Defaults to gpt-oss:20b in codex
|
|
||||||
if model == "" {
|
|
||||||
return []string{"--oss"}
|
|
||||||
}
|
|
||||||
return []string{"--oss", "-m", model}
|
|
||||||
},
|
|
||||||
CheckInstall: checkCommand("codex", "Install with: npm install -g @openai/codex or brew install --cask codex"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var DroidConfig = &AppConfig{
|
|
||||||
Name: "Droid",
|
|
||||||
DisplayName: "Droid",
|
|
||||||
Command: "droid",
|
|
||||||
EnvVars: func(model string) []EnvVar { return nil },
|
|
||||||
Args: func(model string) []string { return nil },
|
|
||||||
Setup: setupDroidSettings,
|
|
||||||
CheckInstall: checkCommand("droid", "Install from: https://docs.factory.ai/cli/getting-started/quickstart"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var AppRegistry = map[string]*AppConfig{
|
|
||||||
"claude": ClaudeConfig,
|
|
||||||
"codex": CodexConfig,
|
|
||||||
"droid": DroidConfig,
|
|
||||||
"opencode": OpenCodeConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetApp(name string) (*AppConfig, bool) {
|
|
||||||
app, ok := AppRegistry[strings.ToLower(name)]
|
|
||||||
return app, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getModelInfo(model string) *api.ShowResponse {
|
|
||||||
client, err := api.ClientFromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
resp, err := client.Show(context.Background(), &api.ShowRequest{Model: model})
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func getModelContextLength(model string) int {
|
|
||||||
const defaultCtx = 64000 // default context is set to 64k to support coding agents
|
|
||||||
resp := getModelInfo(model)
|
|
||||||
if resp == nil || resp.ModelInfo == nil {
|
|
||||||
return defaultCtx
|
|
||||||
}
|
|
||||||
arch, ok := resp.ModelInfo["general.architecture"].(string)
|
|
||||||
if !ok {
|
|
||||||
return defaultCtx
|
|
||||||
}
|
|
||||||
// currently being capped at 128k
|
|
||||||
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)].(float64); ok {
|
|
||||||
return min(int(v), 128000)
|
|
||||||
}
|
|
||||||
return defaultCtx
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelSupportsImages(model string) bool {
|
|
||||||
resp := getModelInfo(model)
|
|
||||||
if resp == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return slices.Contains(resp.Capabilities, "vision")
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
info, err := os.Stat(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Preserve source file permissions (important for files containing API keys)
|
|
||||||
return os.WriteFile(dst, data, info.Mode().Perm())
|
|
||||||
}
|
|
||||||
|
|
||||||
func atomicWriteJSON(path string, data any) error {
|
|
||||||
content, err := json.MarshalIndent(data, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var check any
|
|
||||||
if err := json.Unmarshal(content, &check); err != nil {
|
|
||||||
return fmt.Errorf("validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
if existingContent, err := os.ReadFile(path); err == nil {
|
|
||||||
if !bytes.Equal(existingContent, content) {
|
|
||||||
if err := copyFile(path, backupPath); err != nil {
|
|
||||||
return fmt.Errorf("backup failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp failed: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(content); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("write failed: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("close failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
if _, statErr := os.Stat(backupPath); statErr == nil {
|
|
||||||
os.Rename(backupPath, path)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("rename failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidReasoningEffort(effort string) bool {
|
|
||||||
switch effort {
|
|
||||||
case "high", "medium", "low", "none":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupDroidSettings(models []string) error {
|
|
||||||
if len(models) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsPath := filepath.Join(home, ".factory", "settings.json")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
settings := make(map[string]any)
|
|
||||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
|
||||||
json.Unmarshal(data, &settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
var customModels []any
|
|
||||||
if existing, ok := settings["customModels"].([]any); ok {
|
|
||||||
customModels = existing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep only non-Ollama models (we'll rebuild Ollama models fresh)
|
|
||||||
var nonOllamaModels []any
|
|
||||||
for _, m := range customModels {
|
|
||||||
entry, ok := m.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
nonOllamaModels = append(nonOllamaModels, m)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
displayName, _ := entry["displayName"].(string)
|
|
||||||
if !strings.HasSuffix(displayName, "[Ollama]") {
|
|
||||||
nonOllamaModels = append(nonOllamaModels, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build new Ollama model entries with sequential indices (0, 1, 2, ...)
|
|
||||||
var ollamaModels []any
|
|
||||||
var defaultModelID string
|
|
||||||
for i, model := range models {
|
|
||||||
modelID := fmt.Sprintf("custom:%s-[Ollama]-%d", model, i)
|
|
||||||
newEntry := map[string]any{
|
|
||||||
"model": model,
|
|
||||||
"displayName": fmt.Sprintf("%s [Ollama]", model),
|
|
||||||
"baseUrl": "http://localhost:11434/v1",
|
|
||||||
"apiKey": "ollama",
|
|
||||||
"provider": "generic-chat-completion-api",
|
|
||||||
"maxOutputTokens": getModelContextLength(model),
|
|
||||||
"supportsImages": modelSupportsImages(model),
|
|
||||||
"id": modelID,
|
|
||||||
"index": i,
|
|
||||||
}
|
|
||||||
ollamaModels = append(ollamaModels, newEntry)
|
|
||||||
|
|
||||||
if i == 0 {
|
|
||||||
defaultModelID = modelID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings["customModels"] = append(ollamaModels, nonOllamaModels...)
|
|
||||||
|
|
||||||
sessionSettings, ok := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
sessionSettings = make(map[string]any)
|
|
||||||
}
|
|
||||||
sessionSettings["model"] = defaultModelID
|
|
||||||
|
|
||||||
if effort, ok := sessionSettings["reasoningEffort"].(string); !ok || !isValidReasoningEffort(effort) {
|
|
||||||
sessionSettings["reasoningEffort"] = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
settings["sessionDefaultSettings"] = sessionSettings
|
|
||||||
|
|
||||||
return atomicWriteJSON(settingsPath, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupOpenCodeSettings(modelList []string) error {
|
|
||||||
if len(modelList) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
config := make(map[string]any)
|
|
||||||
if data, err := os.ReadFile(configPath); err == nil {
|
|
||||||
json.Unmarshal(data, &config)
|
|
||||||
}
|
|
||||||
|
|
||||||
config["$schema"] = "https://opencode.ai/config.json"
|
|
||||||
|
|
||||||
provider, ok := config["provider"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
provider = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
ollama, ok := provider["ollama"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
ollama = map[string]any{
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Ollama (local)",
|
|
||||||
"options": map[string]any{
|
|
||||||
"baseURL": "http://localhost:11434/v1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
models, ok := ollama["models"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
models = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedSet := make(map[string]bool)
|
|
||||||
for _, m := range modelList {
|
|
||||||
selectedSet[m] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, cfg := range models {
|
|
||||||
if cfgMap, ok := cfg.(map[string]any); ok {
|
|
||||||
if displayName, ok := cfgMap["name"].(string); ok {
|
|
||||||
if strings.HasSuffix(displayName, "[Ollama]") && !selectedSet[name] {
|
|
||||||
delete(models, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range modelList {
|
|
||||||
models[model] = map[string]any{
|
|
||||||
"name": fmt.Sprintf("%s [Ollama]", model),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ollama["models"] = models
|
|
||||||
provider["ollama"] = ollama
|
|
||||||
config["provider"] = provider
|
|
||||||
|
|
||||||
if err := atomicWriteJSON(configPath, config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
statePath := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
state := map[string]any{
|
|
||||||
"recent": []any{},
|
|
||||||
"favorite": []any{},
|
|
||||||
"variant": map[string]any{},
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile(statePath); err == nil {
|
|
||||||
json.Unmarshal(data, &state)
|
|
||||||
}
|
|
||||||
|
|
||||||
recent, _ := state["recent"].([]any)
|
|
||||||
|
|
||||||
modelSet := make(map[string]bool)
|
|
||||||
for _, m := range modelList {
|
|
||||||
modelSet[m] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
newRecent := []any{}
|
|
||||||
for _, entry := range recent {
|
|
||||||
if e, ok := entry.(map[string]any); ok {
|
|
||||||
if e["providerID"] == "ollama" {
|
|
||||||
if modelID, ok := e["modelID"].(string); ok && modelSet[modelID] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newRecent = append(newRecent, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := len(modelList) - 1; i >= 0; i-- {
|
|
||||||
newRecent = append([]any{map[string]any{
|
|
||||||
"providerID": "ollama",
|
|
||||||
"modelID": modelList[i],
|
|
||||||
}}, newRecent...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(newRecent) > 10 {
|
|
||||||
newRecent = newRecent[:10]
|
|
||||||
}
|
|
||||||
|
|
||||||
state["recent"] = newRecent
|
|
||||||
|
|
||||||
return atomicWriteJSON(statePath, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
var OpenCodeConfig = &AppConfig{
|
|
||||||
Name: "OpenCode",
|
|
||||||
DisplayName: "OpenCode",
|
|
||||||
Command: "opencode",
|
|
||||||
EnvVars: func(model string) []EnvVar { return nil },
|
|
||||||
Args: func(model string) []string { return nil },
|
|
||||||
Setup: setupOpenCodeSettings,
|
|
||||||
CheckInstall: checkCommand("opencode", "Install from: https://opencode.ai"),
|
|
||||||
}
|
|
||||||
|
|
||||||
func readJSONFile(path string) (map[string]any, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOpenCodeOllamaModels() (map[string]any, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := readJSONFile(filepath.Join(home, ".config", "opencode", "opencode.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
provider, _ := config["provider"].(map[string]any)
|
|
||||||
ollama, _ := provider["ollama"].(map[string]any)
|
|
||||||
models, _ := ollama["models"].(map[string]any)
|
|
||||||
return models, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOpenCodeConfiguredModels() []string {
|
|
||||||
models, err := getOpenCodeOllamaModels()
|
|
||||||
if err != nil || models == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for name := range models {
|
|
||||||
result = append(result, name)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOpenCodeConfiguredModel() string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := readJSONFile(filepath.Join(home, ".local", "state", "opencode", "model.json"))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
recent, _ := state["recent"].([]any)
|
|
||||||
if len(recent) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
first, _ := recent[0].(map[string]any)
|
|
||||||
if first["providerID"] == "ollama" {
|
|
||||||
modelID, _ := first["modelID"].(string)
|
|
||||||
return modelID
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDroidSettings() (map[string]any, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readJSONFile(filepath.Join(home, ".factory", "settings.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDroidConfiguredModels() []string {
|
|
||||||
settings, err := readDroidSettings()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
customModels, _ := settings["customModels"].([]any)
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for _, m := range customModels {
|
|
||||||
entry, _ := m.(map[string]any)
|
|
||||||
displayName, _ := entry["displayName"].(string)
|
|
||||||
// Only include Ollama models (those with our displayName pattern)
|
|
||||||
if strings.HasSuffix(displayName, "[Ollama]") {
|
|
||||||
if model, _ := entry["model"].(string); model != "" {
|
|
||||||
result = append(result, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDroidConfiguredModel() string {
|
|
||||||
settings, err := readDroidSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionSettings, _ := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
modelID, _ := sessionSettings["model"].(string)
|
|
||||||
if modelID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
customModels, _ := settings["customModels"].([]any)
|
|
||||||
for _, m := range customModels {
|
|
||||||
entry, _ := m.(map[string]any)
|
|
||||||
if entry["id"] == modelID {
|
|
||||||
model, _ := entry["model"].(string)
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAppConfiguredModels(appName string) []string {
|
|
||||||
// Get models that exist in the app's config
|
|
||||||
var appModels []string
|
|
||||||
switch strings.ToLower(appName) {
|
|
||||||
case "opencode":
|
|
||||||
appModels = getOpenCodeConfiguredModels()
|
|
||||||
case "droid":
|
|
||||||
appModels = getDroidConfiguredModels()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get our saved connection config for the correct order (default first)
|
|
||||||
savedConfig, err := LoadConnection(appName)
|
|
||||||
if err != nil || len(savedConfig.Models) == 0 {
|
|
||||||
return appModels
|
|
||||||
}
|
|
||||||
if len(appModels) == 0 {
|
|
||||||
return savedConfig.Models
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge: saved order first (filtered to still-existing models), then any new models
|
|
||||||
appModelSet := make(map[string]bool, len(appModels))
|
|
||||||
for _, m := range appModels {
|
|
||||||
appModelSet[m] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := make(map[string]bool, len(savedConfig.Models))
|
|
||||||
var result []string
|
|
||||||
for _, m := range savedConfig.Models {
|
|
||||||
if appModelSet[m] {
|
|
||||||
result = append(result, m)
|
|
||||||
seen[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range appModels {
|
|
||||||
if !seen[m] {
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func printModelsAdded(appName string, models []string) {
|
|
||||||
if len(models) == 1 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Added %s to %s\n", models[0], appName)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "Added %d models to %s (default: %s)\n", len(models), appName, models[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runInApp(appName, modelName string) error {
|
|
||||||
app, ok := GetApp(appName)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unknown app: %s", appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.CheckInstall(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.Setup != nil {
|
|
||||||
models := []string{modelName}
|
|
||||||
if config, err := LoadConnection(appName); err == nil && len(config.Models) > 0 {
|
|
||||||
models = config.Models
|
|
||||||
}
|
|
||||||
if err := app.Setup(models); err != nil {
|
|
||||||
return fmt.Errorf("setup failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proc := exec.Command(app.Command, app.Args(modelName)...)
|
|
||||||
proc.Stdin = os.Stdin
|
|
||||||
proc.Stdout = os.Stdout
|
|
||||||
proc.Stderr = os.Stderr
|
|
||||||
proc.Env = os.Environ()
|
|
||||||
for _, env := range app.EnvVars(modelName) {
|
|
||||||
proc.Env = append(proc.Env, fmt.Sprintf("%s=%s", env.Name, env.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "Launching %s with %s...\n", app.DisplayName, modelName)
|
|
||||||
return proc.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCancelled prints the cancellation message and returns true if err is ErrCancelled.
|
|
||||||
// Returns false and the original error otherwise.
|
|
||||||
func handleCancelled(err error) (cancelled bool, origErr error) {
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
fmt.Fprintln(os.Stderr, err.Error())
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConnectCmd() *cobra.Command {
|
|
||||||
var modelFlag string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "connect [APP]",
|
|
||||||
Short: "Configure an external app to use Ollama",
|
|
||||||
Long: `Configure an external application to use Ollama as its backend.
|
|
||||||
|
|
||||||
Supported apps:
|
|
||||||
claude Claude Code
|
|
||||||
codex Codex
|
|
||||||
droid Droid
|
|
||||||
opencode OpenCode
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
ollama connect
|
|
||||||
ollama connect claude
|
|
||||||
ollama connect claude --model llama3.2`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
PreRunE: checkServerHeartbeat,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
var appName string
|
|
||||||
if len(args) > 0 {
|
|
||||||
appName = args[0]
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
appName, err = selectApp()
|
|
||||||
if cancelled, err := handleCancelled(err); cancelled {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := GetApp(appName); !ok {
|
|
||||||
return fmt.Errorf("unknown app: %s", appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var models []string
|
|
||||||
if modelFlag != "" {
|
|
||||||
models = []string{modelFlag}
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
models, err = selectModelForConnect(cmd.Context(), appName)
|
|
||||||
if cancelled, err := handleCancelled(err); cancelled {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveConnection(appName, models); err != nil {
|
|
||||||
return fmt.Errorf("failed to save: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply app-specific configuration (e.g., update OpenCode/Droid config files)
|
|
||||||
app, _ := GetApp(appName)
|
|
||||||
if app.Setup != nil {
|
|
||||||
if err := app.Setup(models); err != nil {
|
|
||||||
return fmt.Errorf("setup failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printModelsAdded(appName, models)
|
|
||||||
|
|
||||||
if launch, _ := confirmLaunch(appName); launch {
|
|
||||||
return runInApp(appName, models[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "Run 'ollama launch %s' to start later\n", strings.ToLower(appName))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAppConfiguredModel(appName string) string {
|
|
||||||
switch strings.ToLower(appName) {
|
|
||||||
case "opencode":
|
|
||||||
return getOpenCodeConfiguredModel()
|
|
||||||
case "droid":
|
|
||||||
return getDroidConfiguredModel()
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrConfigureModel(ctx context.Context, appName string) (string, error) {
|
|
||||||
if modelName := getAppConfiguredModel(appName); modelName != "" {
|
|
||||||
return modelName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if config, err := LoadConnection(appName); err == nil {
|
|
||||||
return config.DefaultModel(), nil
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
models, err := selectModelForConnect(ctx, appName)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveConnection(appName, models); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to save: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
printModelsAdded(appName, models)
|
|
||||||
return models[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LaunchCmd() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "launch [APP]",
|
|
||||||
Short: "Launch a configured app",
|
|
||||||
Long: `Launch a configured application with Ollama as its backend.
|
|
||||||
|
|
||||||
If no app is specified, shows a list of configured apps to choose from.
|
|
||||||
If no apps have been configured, starts the connect flow.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
ollama launch
|
|
||||||
ollama launch claude`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
PreRunE: checkServerHeartbeat,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
var appName string
|
|
||||||
if len(args) > 0 {
|
|
||||||
appName = args[0]
|
|
||||||
} else {
|
|
||||||
selected, err := selectConnectedApp()
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
return nil // Silent exit on cancel
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if selected == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "No apps configured. Let's set one up.\n\n")
|
|
||||||
appName, err = selectApp()
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
appName = selected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, ok := GetApp(appName)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unknown app: %s", appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
modelName, err := getOrConfigureModel(cmd.Context(), appName)
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return runInApp(app.Name, modelName)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
597
cmd/apps_test.go
597
cmd/apps_test.go
@@ -1,597 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetupOpenCodeSettings(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
os.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
configDir := filepath.Join(tmpDir, ".config", "opencode")
|
|
||||||
configPath := filepath.Join(configDir, "opencode.json")
|
|
||||||
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
|
|
||||||
statePath := filepath.Join(stateDir, "model.json")
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
os.RemoveAll(configDir)
|
|
||||||
os.RemoveAll(stateDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fresh install", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserve other providers", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(configDir, 0755)
|
|
||||||
os.WriteFile(configPath, []byte(`{"provider":{"anthropic":{"apiKey":"xxx"}}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, _ := os.ReadFile(configPath)
|
|
||||||
var cfg map[string]any
|
|
||||||
json.Unmarshal(data, &cfg)
|
|
||||||
provider := cfg["provider"].(map[string]any)
|
|
||||||
if provider["anthropic"] == nil {
|
|
||||||
t.Error("anthropic provider was removed")
|
|
||||||
}
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserve other models", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(configDir, 0755)
|
|
||||||
os.WriteFile(configPath, []byte(`{"provider":{"ollama":{"models":{"mistral":{"name":"Mistral"}}}}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertOpenCodeModelExists(t, configPath, "mistral")
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("update existing model", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
setupOpenCodeSettings([]string{"llama3.2"})
|
|
||||||
setupOpenCodeSettings([]string{"llama3.2"})
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserve top-level keys", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(configDir, 0755)
|
|
||||||
os.WriteFile(configPath, []byte(`{"theme":"dark","keybindings":{}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, _ := os.ReadFile(configPath)
|
|
||||||
var cfg map[string]any
|
|
||||||
json.Unmarshal(data, &cfg)
|
|
||||||
if cfg["theme"] != "dark" {
|
|
||||||
t.Error("theme was removed")
|
|
||||||
}
|
|
||||||
if cfg["keybindings"] == nil {
|
|
||||||
t.Error("keybindings was removed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("model state - insert at index 0", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(stateDir, 0755)
|
|
||||||
os.WriteFile(statePath, []byte(`{"recent":[{"providerID":"anthropic","modelID":"claude"}],"favorite":[],"variant":{}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
|
||||||
assertOpenCodeRecentModel(t, statePath, 1, "anthropic", "claude")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("model state - preserve favorites and variants", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(stateDir, 0755)
|
|
||||||
os.WriteFile(statePath, []byte(`{"recent":[],"favorite":[{"providerID":"x","modelID":"y"}],"variant":{"a":"b"}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, _ := os.ReadFile(statePath)
|
|
||||||
var state map[string]any
|
|
||||||
json.Unmarshal(data, &state)
|
|
||||||
if len(state["favorite"].([]any)) != 1 {
|
|
||||||
t.Error("favorite was modified")
|
|
||||||
}
|
|
||||||
if state["variant"].(map[string]any)["a"] != "b" {
|
|
||||||
t.Error("variant was modified")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("model state - deduplicate on re-add", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(stateDir, 0755)
|
|
||||||
os.WriteFile(statePath, []byte(`{"recent":[{"providerID":"ollama","modelID":"llama3.2"},{"providerID":"anthropic","modelID":"claude"}],"favorite":[],"variant":{}}`), 0644)
|
|
||||||
if err := setupOpenCodeSettings([]string{"llama3.2"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, _ := os.ReadFile(statePath)
|
|
||||||
var state map[string]any
|
|
||||||
json.Unmarshal(data, &state)
|
|
||||||
recent := state["recent"].([]any)
|
|
||||||
if len(recent) != 2 {
|
|
||||||
t.Errorf("expected 2 recent entries, got %d", len(recent))
|
|
||||||
}
|
|
||||||
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("remove model", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
// First add two models
|
|
||||||
setupOpenCodeSettings([]string{"llama3.2", "mistral"})
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
assertOpenCodeModelExists(t, configPath, "mistral")
|
|
||||||
|
|
||||||
// Then remove one by only selecting the other
|
|
||||||
setupOpenCodeSettings([]string{"llama3.2"})
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("remove model preserves non-ollama models", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(configDir, 0755)
|
|
||||||
// Add a non-Ollama model manually
|
|
||||||
os.WriteFile(configPath, []byte(`{"provider":{"ollama":{"models":{"external":{"name":"External Model"}}}}}`), 0644)
|
|
||||||
|
|
||||||
setupOpenCodeSettings([]string{"llama3.2"})
|
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
|
||||||
assertOpenCodeModelExists(t, configPath, "external") // Should be preserved
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertOpenCodeModelExists(t *testing.T, path, model string) {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var cfg map[string]any
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
provider, ok := cfg["provider"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("provider not found")
|
|
||||||
}
|
|
||||||
ollama, ok := provider["ollama"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("ollama provider not found")
|
|
||||||
}
|
|
||||||
models, ok := ollama["models"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("models not found")
|
|
||||||
}
|
|
||||||
if models[model] == nil {
|
|
||||||
t.Errorf("model %s not found", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertOpenCodeModelNotExists(t *testing.T, path, model string) {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var cfg map[string]any
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
provider, ok := cfg["provider"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return // No provider means no model
|
|
||||||
}
|
|
||||||
ollama, ok := provider["ollama"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return // No ollama means no model
|
|
||||||
}
|
|
||||||
models, ok := ollama["models"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return // No models means no model
|
|
||||||
}
|
|
||||||
if models[model] != nil {
|
|
||||||
t.Errorf("model %s should not exist but was found", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertOpenCodeRecentModel(t *testing.T, path string, index int, providerID, modelID string) {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var state map[string]any
|
|
||||||
if err := json.Unmarshal(data, &state); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
recent, ok := state["recent"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("recent not found")
|
|
||||||
}
|
|
||||||
if index >= len(recent) {
|
|
||||||
t.Fatalf("index %d out of range (len=%d)", index, len(recent))
|
|
||||||
}
|
|
||||||
entry, ok := recent[index].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("entry is not a map")
|
|
||||||
}
|
|
||||||
if entry["providerID"] != providerID {
|
|
||||||
t.Errorf("expected providerID %s, got %s", providerID, entry["providerID"])
|
|
||||||
}
|
|
||||||
if entry["modelID"] != modelID {
|
|
||||||
t.Errorf("expected modelID %s, got %s", modelID, entry["modelID"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetupDroidSettings(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
os.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
settingsDir := filepath.Join(tmpDir, ".factory")
|
|
||||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
os.RemoveAll(settingsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
readSettings := func() map[string]any {
|
|
||||||
data, _ := os.ReadFile(settingsPath)
|
|
||||||
var settings map[string]any
|
|
||||||
json.Unmarshal(data, &settings)
|
|
||||||
return settings
|
|
||||||
}
|
|
||||||
|
|
||||||
getCustomModels := func(settings map[string]any) []map[string]any {
|
|
||||||
models, ok := settings["customModels"].([]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var result []map[string]any
|
|
||||||
for _, m := range models {
|
|
||||||
if entry, ok := m.(map[string]any); ok {
|
|
||||||
result = append(result, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("fresh install creates models with sequential indices", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
if err := setupDroidSettings([]string{"model-a", "model-b"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
models := getCustomModels(settings)
|
|
||||||
|
|
||||||
if len(models) != 2 {
|
|
||||||
t.Fatalf("expected 2 models, got %d", len(models))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check first model
|
|
||||||
if models[0]["model"] != "model-a" {
|
|
||||||
t.Errorf("expected model-a, got %s", models[0]["model"])
|
|
||||||
}
|
|
||||||
if models[0]["id"] != "custom:model-a-[Ollama]-0" {
|
|
||||||
t.Errorf("expected custom:model-a-[Ollama]-0, got %s", models[0]["id"])
|
|
||||||
}
|
|
||||||
if models[0]["index"] != float64(0) {
|
|
||||||
t.Errorf("expected index 0, got %v", models[0]["index"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check second model
|
|
||||||
if models[1]["model"] != "model-b" {
|
|
||||||
t.Errorf("expected model-b, got %s", models[1]["model"])
|
|
||||||
}
|
|
||||||
if models[1]["id"] != "custom:model-b-[Ollama]-1" {
|
|
||||||
t.Errorf("expected custom:model-b-[Ollama]-1, got %s", models[1]["id"])
|
|
||||||
}
|
|
||||||
if models[1]["index"] != float64(1) {
|
|
||||||
t.Errorf("expected index 1, got %v", models[1]["index"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
if err := setupDroidSettings([]string{"model-a", "model-b"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
session, ok := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("sessionDefaultSettings not found")
|
|
||||||
}
|
|
||||||
if session["model"] != "custom:model-a-[Ollama]-0" {
|
|
||||||
t.Errorf("expected custom:model-a-[Ollama]-0, got %s", session["model"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("re-indexes when models removed", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
// Add three models
|
|
||||||
setupDroidSettings([]string{"model-a", "model-b", "model-c"})
|
|
||||||
|
|
||||||
// Remove middle model
|
|
||||||
setupDroidSettings([]string{"model-a", "model-c"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
models := getCustomModels(settings)
|
|
||||||
|
|
||||||
if len(models) != 2 {
|
|
||||||
t.Fatalf("expected 2 models, got %d", len(models))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check indices are sequential 0, 1
|
|
||||||
if models[0]["index"] != float64(0) {
|
|
||||||
t.Errorf("expected index 0, got %v", models[0]["index"])
|
|
||||||
}
|
|
||||||
if models[1]["index"] != float64(1) {
|
|
||||||
t.Errorf("expected index 1, got %v", models[1]["index"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check IDs match new indices
|
|
||||||
if models[0]["id"] != "custom:model-a-[Ollama]-0" {
|
|
||||||
t.Errorf("expected custom:model-a-[Ollama]-0, got %s", models[0]["id"])
|
|
||||||
}
|
|
||||||
if models[1]["id"] != "custom:model-c-[Ollama]-1" {
|
|
||||||
t.Errorf("expected custom:model-c-[Ollama]-1, got %s", models[1]["id"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves non-Ollama custom models", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(settingsDir, 0755)
|
|
||||||
// Pre-existing non-Ollama model
|
|
||||||
os.WriteFile(settingsPath, []byte(`{
|
|
||||||
"customModels": [
|
|
||||||
{"model": "gpt-4", "displayName": "GPT-4", "provider": "openai"}
|
|
||||||
]
|
|
||||||
}`), 0644)
|
|
||||||
|
|
||||||
setupDroidSettings([]string{"model-a"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
models := getCustomModels(settings)
|
|
||||||
|
|
||||||
if len(models) != 2 {
|
|
||||||
t.Fatalf("expected 2 models (1 Ollama + 1 non-Ollama), got %d", len(models))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ollama model should be first
|
|
||||||
if models[0]["model"] != "model-a" {
|
|
||||||
t.Errorf("expected Ollama model first, got %s", models[0]["model"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-Ollama model should be preserved at end
|
|
||||||
if models[1]["model"] != "gpt-4" {
|
|
||||||
t.Errorf("expected gpt-4 preserved, got %s", models[1]["model"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves other settings", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(settingsDir, 0755)
|
|
||||||
os.WriteFile(settingsPath, []byte(`{
|
|
||||||
"theme": "dark",
|
|
||||||
"enableHooks": true,
|
|
||||||
"sessionDefaultSettings": {"autonomyMode": "auto-high"}
|
|
||||||
}`), 0644)
|
|
||||||
|
|
||||||
setupDroidSettings([]string{"model-a"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
|
|
||||||
if settings["theme"] != "dark" {
|
|
||||||
t.Error("theme was not preserved")
|
|
||||||
}
|
|
||||||
if settings["enableHooks"] != true {
|
|
||||||
t.Error("enableHooks was not preserved")
|
|
||||||
}
|
|
||||||
|
|
||||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
if session["autonomyMode"] != "auto-high" {
|
|
||||||
t.Error("autonomyMode was not preserved")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("required fields present", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
setupDroidSettings([]string{"test-model"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
models := getCustomModels(settings)
|
|
||||||
|
|
||||||
if len(models) != 1 {
|
|
||||||
t.Fatal("expected 1 model")
|
|
||||||
}
|
|
||||||
|
|
||||||
model := models[0]
|
|
||||||
requiredFields := []string{"model", "displayName", "baseUrl", "apiKey", "provider", "maxOutputTokens", "id", "index"}
|
|
||||||
for _, field := range requiredFields {
|
|
||||||
if model[field] == nil {
|
|
||||||
t.Errorf("missing required field: %s", field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if model["baseUrl"] != "http://localhost:11434/v1" {
|
|
||||||
t.Errorf("unexpected baseUrl: %s", model["baseUrl"])
|
|
||||||
}
|
|
||||||
if model["apiKey"] != "ollama" {
|
|
||||||
t.Errorf("unexpected apiKey: %s", model["apiKey"])
|
|
||||||
}
|
|
||||||
if model["provider"] != "generic-chat-completion-api" {
|
|
||||||
t.Errorf("unexpected provider: %s", model["provider"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fixes invalid reasoningEffort", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(settingsDir, 0755)
|
|
||||||
// Pre-existing settings with invalid reasoningEffort
|
|
||||||
os.WriteFile(settingsPath, []byte(`{
|
|
||||||
"sessionDefaultSettings": {"reasoningEffort": "off"}
|
|
||||||
}`), 0644)
|
|
||||||
|
|
||||||
setupDroidSettings([]string{"model-a"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
|
|
||||||
if session["reasoningEffort"] != "none" {
|
|
||||||
t.Errorf("expected reasoningEffort to be fixed to 'none', got %s", session["reasoningEffort"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves valid reasoningEffort", func(t *testing.T) {
|
|
||||||
cleanup()
|
|
||||||
os.MkdirAll(settingsDir, 0755)
|
|
||||||
os.WriteFile(settingsPath, []byte(`{
|
|
||||||
"sessionDefaultSettings": {"reasoningEffort": "high"}
|
|
||||||
}`), 0644)
|
|
||||||
|
|
||||||
setupDroidSettings([]string{"model-a"})
|
|
||||||
|
|
||||||
settings := readSettings()
|
|
||||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
|
||||||
|
|
||||||
if session["reasoningEffort"] != "high" {
|
|
||||||
t.Errorf("expected reasoningEffort to remain 'high', got %s", session["reasoningEffort"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAtomicWriteJSON(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
t.Run("creates file", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "new.json")
|
|
||||||
data := map[string]string{"key": "value"}
|
|
||||||
|
|
||||||
if err := atomicWriteJSON(path, data); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]string
|
|
||||||
if err := json.Unmarshal(content, &result); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result["key"] != "value" {
|
|
||||||
t.Errorf("expected value, got %s", result["key"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("creates backup", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "backup.json")
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
|
|
||||||
// Write initial file
|
|
||||||
os.WriteFile(path, []byte(`{"original": true}`), 0644)
|
|
||||||
|
|
||||||
// Update with atomicWriteJSON
|
|
||||||
if err := atomicWriteJSON(path, map[string]bool{"updated": true}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check backup exists with original content
|
|
||||||
backup, err := os.ReadFile(backupPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("backup file not created")
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupData map[string]bool
|
|
||||||
json.Unmarshal(backup, &backupData)
|
|
||||||
if !backupData["original"] {
|
|
||||||
t.Error("backup doesn't contain original data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check new file has updated content
|
|
||||||
current, _ := os.ReadFile(path)
|
|
||||||
var currentData map[string]bool
|
|
||||||
json.Unmarshal(current, ¤tData)
|
|
||||||
if !currentData["updated"] {
|
|
||||||
t.Error("file doesn't contain updated data")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no backup for new file", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "nobak.json")
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
|
|
||||||
if err := atomicWriteJSON(path, map[string]string{"new": "file"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
|
|
||||||
t.Error("backup should not exist for new file")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("valid JSON output", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "valid.json")
|
|
||||||
data := map[string]any{
|
|
||||||
"string": "hello",
|
|
||||||
"number": 42,
|
|
||||||
"nested": map[string]string{"a": "b"},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := atomicWriteJSON(path, data); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, _ := os.ReadFile(path)
|
|
||||||
var parsed map[string]any
|
|
||||||
if err := json.Unmarshal(content, &parsed); err != nil {
|
|
||||||
t.Errorf("output is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no backup when content unchanged", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "unchanged.json")
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
|
|
||||||
data := map[string]string{"key": "value"}
|
|
||||||
|
|
||||||
// First write
|
|
||||||
if err := atomicWriteJSON(path, data); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a "stale" backup to verify it's not overwritten
|
|
||||||
os.WriteFile(backupPath, []byte(`{"stale": "backup"}`), 0644)
|
|
||||||
|
|
||||||
// Second write with same content
|
|
||||||
if err := atomicWriteJSON(path, data); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup should still contain stale content (not overwritten)
|
|
||||||
backup, _ := os.ReadFile(backupPath)
|
|
||||||
if string(backup) != `{"stale": "backup"}` {
|
|
||||||
t.Errorf("backup was overwritten when content unchanged")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1945,8 +1945,6 @@ func NewCLI() *cobra.Command {
|
|||||||
copyCmd,
|
copyCmd,
|
||||||
deleteCmd,
|
deleteCmd,
|
||||||
runnerCmd,
|
runnerCmd,
|
||||||
ConnectCmd(),
|
|
||||||
LaunchCmd(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
|
|||||||
112
cmd/config.go
112
cmd/config.go
@@ -1,112 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConnectionConfig struct {
|
|
||||||
App string `json:"app"`
|
|
||||||
Models []string `json:"models"`
|
|
||||||
ConfiguredAt time.Time `json:"configured_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultModel returns the first (default) model, or empty string if none
|
|
||||||
func (c *ConnectionConfig) DefaultModel() string {
|
|
||||||
if len(c.Models) > 0 {
|
|
||||||
return c.Models[0]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectionsDir() (string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".ollama", "connections"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func configPath(appName string) (string, error) {
|
|
||||||
dir, err := connectionsDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// Normalize to lowercase for consistent file naming across case-sensitive filesystems
|
|
||||||
return filepath.Join(dir, strings.ToLower(appName)+".json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveConnection(appName string, models []string) error {
|
|
||||||
path, err := configPath(appName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return atomicWriteJSON(path, ConnectionConfig{
|
|
||||||
App: appName,
|
|
||||||
Models: models,
|
|
||||||
ConfiguredAt: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConnection(appName string) (*ConnectionConfig, error) {
|
|
||||||
path, err := configPath(appName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var config ConnectionConfig
|
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListConnections() ([]ConnectionConfig, error) {
|
|
||||||
dir, err := connectionsDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configs []ConnectionConfig
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var config ConnectionConfig
|
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
configs = append(configs, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -37,8 +37,6 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
||||||
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
||||||
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
||||||
fmt.Fprintln(os.Stderr, " /connect Configure an external app to use Ollama")
|
|
||||||
fmt.Fprintln(os.Stderr, " /launch [app] Launch a configured app")
|
|
||||||
fmt.Fprintln(os.Stderr, " /bye Exit")
|
fmt.Fprintln(os.Stderr, " /bye Exit")
|
||||||
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
||||||
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
||||||
@@ -462,104 +460,6 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
}
|
}
|
||||||
case strings.HasPrefix(line, "/exit"), strings.HasPrefix(line, "/bye"):
|
case strings.HasPrefix(line, "/exit"), strings.HasPrefix(line, "/bye"):
|
||||||
return nil
|
return nil
|
||||||
case strings.HasPrefix(line, "/connect"):
|
|
||||||
args := strings.Fields(line)
|
|
||||||
var appName string
|
|
||||||
if len(args) > 1 {
|
|
||||||
appName = args[1]
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
appName, err = selectApp()
|
|
||||||
if cancelled, _ := handleCancelled(err); cancelled {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := GetApp(appName); !ok {
|
|
||||||
fmt.Printf("Unknown app: %s\n", appName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
models, err := selectModelForConnect(cmd.Context(), appName)
|
|
||||||
if cancelled, _ := handleCancelled(err); cancelled {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveConnection(appName, models); err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
printModelsAdded(appName, models)
|
|
||||||
|
|
||||||
if launch, _ := confirmLaunch(appName); launch {
|
|
||||||
if err := runInApp(appName, models[0]); err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "/launch"):
|
|
||||||
args := strings.Fields(line)
|
|
||||||
var appName string
|
|
||||||
if len(args) >= 2 {
|
|
||||||
appName = args[1]
|
|
||||||
} else {
|
|
||||||
selected, err := selectConnectedApp()
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
continue // Silent exit on cancel
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if selected == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "No apps configured. Let's set one up.\n\n")
|
|
||||||
appName, err = selectApp()
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
appName = selected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, ok := GetApp(appName)
|
|
||||||
if !ok {
|
|
||||||
fmt.Printf("Unknown app: %s\n", appName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
modelName, err := getOrConfigureModel(cmd.Context(), appName)
|
|
||||||
if errors.Is(err, ErrCancelled) {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Model != "" && modelName != opts.Model {
|
|
||||||
if switchModel, _ := confirmPrompt(fmt.Sprintf("Switch %s to use %s?", app.DisplayName, opts.Model)); switchModel {
|
|
||||||
modelName = opts.Model
|
|
||||||
if err := SaveConnection(appName, []string{modelName}); err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Updated %s to %s\n", appName, modelName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runInApp(app.Name, modelName); err != nil {
|
|
||||||
fmt.Printf("error: %v\n", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "/"):
|
case strings.HasPrefix(line, "/"):
|
||||||
args := strings.Fields(line)
|
args := strings.Fields(line)
|
||||||
isFile := false
|
isFile := false
|
||||||
|
|||||||
562
cmd/selector.go
562
cmd/selector.go
@@ -1,562 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/term"
|
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxDisplayedItems = 10
|
|
||||||
|
|
||||||
var AppOrder = []string{"claude", "codex", "opencode", "droid"}
|
|
||||||
|
|
||||||
var ErrCancelled = errors.New("no ollama connections created")
|
|
||||||
|
|
||||||
type SelectItem struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// terminalState manages raw terminal mode for interactive selection
|
|
||||||
type terminalState struct {
|
|
||||||
fd int
|
|
||||||
oldState *term.State
|
|
||||||
}
|
|
||||||
|
|
||||||
// enterRawMode puts terminal in raw mode with cursor hidden
|
|
||||||
func enterRawMode() (*terminalState, error) {
|
|
||||||
fd := int(os.Stdin.Fd())
|
|
||||||
oldState, err := term.MakeRaw(fd)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fmt.Fprint(os.Stderr, "\033[?25l") // hide cursor
|
|
||||||
return &terminalState{fd: fd, oldState: oldState}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore restores terminal state and shows cursor
|
|
||||||
func (t *terminalState) restore() {
|
|
||||||
fmt.Fprint(os.Stderr, "\033[?25h") // show cursor
|
|
||||||
term.Restore(t.fd, t.oldState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearLines moves cursor up n lines and clears from there
|
|
||||||
func clearLines(n int) {
|
|
||||||
if n > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "\033[%dA", n)
|
|
||||||
fmt.Fprint(os.Stderr, "\033[J")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Select(prompt string, items []SelectItem) (string, error) {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "", fmt.Errorf("no items to select from")
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := enterRawMode()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer ts.restore()
|
|
||||||
|
|
||||||
var filter string
|
|
||||||
selected := 0
|
|
||||||
scrollOffset := 0
|
|
||||||
var lastLineCount int
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
filtered := filterItems(items, filter)
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "%s %s\r\n", prompt, filter)
|
|
||||||
lineCount := 1
|
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m(no matches)\033[0m\r\n")
|
|
||||||
lineCount++
|
|
||||||
} else {
|
|
||||||
displayCount := min(len(filtered), maxDisplayedItems)
|
|
||||||
|
|
||||||
for i := range displayCount {
|
|
||||||
idx := scrollOffset + i
|
|
||||||
if idx >= len(filtered) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
item := filtered[idx]
|
|
||||||
prefix := " "
|
|
||||||
if idx == selected {
|
|
||||||
prefix = " \033[1m> "
|
|
||||||
}
|
|
||||||
if item.Description != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s%s\033[0m \033[37m- %s\033[0m\r\n", prefix, item.Name, item.Description)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s%s\033[0m\r\n", prefix, item.Name)
|
|
||||||
}
|
|
||||||
lineCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
if remaining := len(filtered) - scrollOffset - displayCount; remaining > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m... and %d more\033[0m\r\n", remaining)
|
|
||||||
lineCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLineCount = lineCount
|
|
||||||
}
|
|
||||||
|
|
||||||
render()
|
|
||||||
|
|
||||||
buf := make([]byte, 3)
|
|
||||||
for {
|
|
||||||
n, err := os.Stdin.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := filterItems(items, filter)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case n == 1 && buf[0] == 13: // Enter
|
|
||||||
if len(filtered) > 0 && selected < len(filtered) {
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
return filtered[selected].Name, nil
|
|
||||||
}
|
|
||||||
case n == 1 && (buf[0] == 3 || buf[0] == 27): // Ctrl+C or Escape
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
return "", ErrCancelled
|
|
||||||
case n == 1 && buf[0] == 127: // Backspace
|
|
||||||
if len(filter) > 0 {
|
|
||||||
filter = filter[:len(filter)-1]
|
|
||||||
selected = 0
|
|
||||||
scrollOffset = 0
|
|
||||||
}
|
|
||||||
case n == 3 && buf[0] == 27 && buf[1] == 91: // Arrow keys
|
|
||||||
if buf[2] == 65 && selected > 0 { // Up
|
|
||||||
selected--
|
|
||||||
if selected < scrollOffset {
|
|
||||||
scrollOffset = selected
|
|
||||||
}
|
|
||||||
} else if buf[2] == 66 && selected < len(filtered)-1 { // Down
|
|
||||||
selected++
|
|
||||||
if selected >= scrollOffset+maxDisplayedItems {
|
|
||||||
scrollOffset = selected - maxDisplayedItems + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case n == 1 && buf[0] >= 32 && buf[0] < 127: // Printable chars
|
|
||||||
filter += string(buf[0])
|
|
||||||
selected = 0
|
|
||||||
scrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterItems(items []SelectItem, filter string) []SelectItem {
|
|
||||||
if filter == "" {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
var result []SelectItem
|
|
||||||
filterLower := strings.ToLower(filter)
|
|
||||||
for _, item := range items {
|
|
||||||
if strings.Contains(strings.ToLower(item.Name), filterLower) {
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func MultiSelect(prompt string, items []SelectItem, preChecked []string) ([]string, error) {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil, fmt.Errorf("no items to select from")
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := enterRawMode()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer ts.restore()
|
|
||||||
|
|
||||||
var filter string
|
|
||||||
highlighted := 0
|
|
||||||
scrollOffset := 0
|
|
||||||
checked := make(map[int]bool)
|
|
||||||
var checkOrder []int
|
|
||||||
var lastLineCount int
|
|
||||||
focusOnButton := false
|
|
||||||
|
|
||||||
// Build index lookup for O(1) access
|
|
||||||
itemIndex := make(map[string]int, len(items))
|
|
||||||
for i, item := range items {
|
|
||||||
itemIndex[item.Name] = i
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-check items in their original order (preserves default as first)
|
|
||||||
for _, name := range preChecked {
|
|
||||||
if idx, ok := itemIndex[name]; ok {
|
|
||||||
checked[idx] = true
|
|
||||||
checkOrder = append(checkOrder, idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
filtered := filterItems(items, filter)
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "%s %s\r\n", prompt, filter)
|
|
||||||
lineCount := 1
|
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m(no matches)\033[0m\r\n")
|
|
||||||
lineCount++
|
|
||||||
} else {
|
|
||||||
displayCount := min(len(filtered), maxDisplayedItems)
|
|
||||||
|
|
||||||
for i := range displayCount {
|
|
||||||
idx := scrollOffset + i
|
|
||||||
if idx >= len(filtered) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
item := filtered[idx]
|
|
||||||
origIdx := itemIndex[item.Name]
|
|
||||||
|
|
||||||
checkbox := "[ ]"
|
|
||||||
if checked[origIdx] {
|
|
||||||
checkbox = "[x]"
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := " "
|
|
||||||
suffix := ""
|
|
||||||
if idx == highlighted && !focusOnButton {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
if len(checkOrder) > 0 && checkOrder[0] == origIdx {
|
|
||||||
suffix = " \033[37m(default)\033[0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx == highlighted && !focusOnButton {
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s %s %s\033[0m%s\r\n", prefix, checkbox, item.Name, suffix)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s %s %s%s\r\n", prefix, checkbox, item.Name, suffix)
|
|
||||||
}
|
|
||||||
lineCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
if remaining := len(filtered) - scrollOffset - displayCount; remaining > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m... and %d more\033[0m\r\n", remaining)
|
|
||||||
lineCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue button
|
|
||||||
fmt.Fprintf(os.Stderr, "\r\n")
|
|
||||||
lineCount++
|
|
||||||
count := len(checkOrder)
|
|
||||||
switch {
|
|
||||||
case count == 0:
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37mSelect at least one model.\033[0m\r\n")
|
|
||||||
case focusOnButton:
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m> [ Continue ]\033[0m \033[37m(%d selected)\033[0m\r\n", count)
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m[ Continue ] (%d selected) - press Tab\033[0m\r\n", count)
|
|
||||||
}
|
|
||||||
lineCount++
|
|
||||||
|
|
||||||
lastLineCount = lineCount
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleItem := func() {
|
|
||||||
filtered := filterItems(items, filter)
|
|
||||||
if len(filtered) == 0 || highlighted >= len(filtered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
item := filtered[highlighted]
|
|
||||||
origIdx := itemIndex[item.Name]
|
|
||||||
|
|
||||||
if checked[origIdx] {
|
|
||||||
delete(checked, origIdx)
|
|
||||||
for i, idx := range checkOrder {
|
|
||||||
if idx == origIdx {
|
|
||||||
checkOrder = append(checkOrder[:i], checkOrder[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
checked[origIdx] = true
|
|
||||||
checkOrder = append(checkOrder, origIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render()
|
|
||||||
|
|
||||||
buf := make([]byte, 3)
|
|
||||||
for {
|
|
||||||
n, err := os.Stdin.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := filterItems(items, filter)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case n == 1 && buf[0] == 13: // Enter
|
|
||||||
if focusOnButton && len(checkOrder) > 0 {
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
var result []string
|
|
||||||
for _, idx := range checkOrder {
|
|
||||||
result = append(result, items[idx].Name)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
} else if !focusOnButton {
|
|
||||||
toggleItem()
|
|
||||||
}
|
|
||||||
case n == 1 && buf[0] == 9: // Tab
|
|
||||||
if len(checkOrder) > 0 {
|
|
||||||
focusOnButton = !focusOnButton
|
|
||||||
}
|
|
||||||
case n == 1 && (buf[0] == 3 || buf[0] == 27): // Ctrl+C or Escape
|
|
||||||
clearLines(lastLineCount)
|
|
||||||
return nil, ErrCancelled
|
|
||||||
case n == 1 && buf[0] == 127: // Backspace
|
|
||||||
if len(filter) > 0 {
|
|
||||||
filter = filter[:len(filter)-1]
|
|
||||||
highlighted = 0
|
|
||||||
scrollOffset = 0
|
|
||||||
focusOnButton = false
|
|
||||||
}
|
|
||||||
case n == 3 && buf[0] == 27 && buf[1] == 91: // Arrow keys
|
|
||||||
if focusOnButton {
|
|
||||||
// Any arrow key returns focus to list
|
|
||||||
focusOnButton = false
|
|
||||||
} else {
|
|
||||||
if buf[2] == 65 && highlighted > 0 { // Up
|
|
||||||
highlighted--
|
|
||||||
if highlighted < scrollOffset {
|
|
||||||
scrollOffset = highlighted
|
|
||||||
}
|
|
||||||
} else if buf[2] == 66 && highlighted < len(filtered)-1 { // Down
|
|
||||||
highlighted++
|
|
||||||
if highlighted >= scrollOffset+maxDisplayedItems {
|
|
||||||
scrollOffset = highlighted - maxDisplayedItems + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case n == 1 && buf[0] >= 32 && buf[0] < 127: // Printable chars
|
|
||||||
filter += string(buf[0])
|
|
||||||
highlighted = 0
|
|
||||||
scrollOffset = 0
|
|
||||||
focusOnButton = false
|
|
||||||
}
|
|
||||||
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectApp() (string, error) {
|
|
||||||
var items []SelectItem
|
|
||||||
|
|
||||||
for _, name := range AppOrder {
|
|
||||||
app, ok := AppRegistry[name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
description := app.DisplayName
|
|
||||||
// Show configured model if one exists
|
|
||||||
if conn, err := LoadConnection(name); err == nil && conn.DefaultModel() != "" {
|
|
||||||
description = fmt.Sprintf("%s (%s)", app.DisplayName, conn.DefaultModel())
|
|
||||||
}
|
|
||||||
items = append(items, SelectItem{Name: app.Name, Description: description})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "", fmt.Errorf("no apps available")
|
|
||||||
}
|
|
||||||
|
|
||||||
return Select("Select app:", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectConnectedApp() (string, error) {
|
|
||||||
connections, err := ListConnections()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(connections) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []SelectItem
|
|
||||||
for _, conn := range connections {
|
|
||||||
app, ok := GetApp(conn.App)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, SelectItem{
|
|
||||||
Name: app.Name,
|
|
||||||
Description: fmt.Sprintf("%s (%s)", app.DisplayName, conn.DefaultModel()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return Select("Select app to launch (use ollama connect to configure other apps):", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmLaunch(appName string) (bool, error) {
|
|
||||||
return confirmPrompt(fmt.Sprintf("Launch %s now?", appName))
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmPrompt(prompt string) (bool, error) {
|
|
||||||
fd := int(os.Stdin.Fd())
|
|
||||||
oldState, err := term.MakeRaw(fd)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer term.Restore(fd, oldState)
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "%s [y/n] ", prompt)
|
|
||||||
|
|
||||||
buf := make([]byte, 1)
|
|
||||||
for {
|
|
||||||
if _, err := os.Stdin.Read(buf); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch buf[0] {
|
|
||||||
case 'Y', 'y', 13: // Yes, Enter
|
|
||||||
fmt.Fprintf(os.Stderr, "yes\r\n")
|
|
||||||
return true, nil
|
|
||||||
case 'N', 'n', 27, 3: // No, Escape, Ctrl+C
|
|
||||||
fmt.Fprintf(os.Stderr, "no\r\n")
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectModelForConnect(ctx context.Context, appName string) ([]string, error) {
|
|
||||||
client, err := api.ClientFromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
models, err := client.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(models.Models) == 0 {
|
|
||||||
return nil, fmt.Errorf("no models available. Run 'ollama pull <model>' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []SelectItem
|
|
||||||
cloudModels := make(map[string]bool)
|
|
||||||
for _, m := range models.Models {
|
|
||||||
if m.RemoteModel != "" {
|
|
||||||
cloudModels[m.Name] = true
|
|
||||||
}
|
|
||||||
items = append(items, SelectItem{Name: m.Name})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil, fmt.Errorf("no local models available. Run 'ollama pull <model>' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get already configured models for this app to pre-check
|
|
||||||
preChecked := getAppConfiguredModels(appName)
|
|
||||||
preCheckedSet := make(map[string]bool)
|
|
||||||
for _, name := range preChecked {
|
|
||||||
preCheckedSet[name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
iName, jName := strings.ToLower(items[i].Name), strings.ToLower(items[j].Name)
|
|
||||||
iChecked, jChecked := preCheckedSet[items[i].Name], preCheckedSet[items[j].Name]
|
|
||||||
|
|
||||||
// Pre-checked models come first
|
|
||||||
if iChecked != jChecked {
|
|
||||||
return iChecked
|
|
||||||
}
|
|
||||||
|
|
||||||
// Within each group, sort alphabetically
|
|
||||||
return iName < jName
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apps with config files support multi-model, others use single-select
|
|
||||||
app, _ := GetApp(appName)
|
|
||||||
supportsMultiModel := app != nil && app.Setup != nil
|
|
||||||
|
|
||||||
var selected []string
|
|
||||||
if supportsMultiModel {
|
|
||||||
selected, err = MultiSelect(fmt.Sprintf("Select models for %s:", app.DisplayName), items, preChecked)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
model, err := Select(fmt.Sprintf("Select model for %s:", app.DisplayName), items)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
selected = []string{model}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any selected model is cloud, ensure signed in once
|
|
||||||
for _, model := range selected {
|
|
||||||
if cloudModels[model] {
|
|
||||||
if err := ensureSignedIn(ctx, client); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureSignedIn(ctx context.Context, client *api.Client) error {
|
|
||||||
user, err := client.Whoami(ctx)
|
|
||||||
if err == nil && user != nil && user.Name != "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var aErr api.AuthorizationError
|
|
||||||
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
yes, err := confirmPrompt("Sign in to ollama.com?")
|
|
||||||
if err != nil || !yes {
|
|
||||||
return fmt.Errorf("sign in required for cloud models")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL)
|
|
||||||
fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete...\033[0m")
|
|
||||||
|
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
fmt.Fprintf(os.Stderr, "\n")
|
|
||||||
return ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
user, err := client.Whoami(ctx)
|
|
||||||
if err == nil && user != nil && user.Name != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", user.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ _build_darwin() {
|
|||||||
MLX_CGO_CFLAGS="-O3 -I$(pwd)/$BUILD_DIR/_deps/mlx-c-src -mmacosx-version-min=14.0"
|
MLX_CGO_CFLAGS="-O3 -I$(pwd)/$BUILD_DIR/_deps/mlx-c-src -mmacosx-version-min=14.0"
|
||||||
MLX_CGO_LDFLAGS="-L$(pwd)/$BUILD_DIR/lib/ollama -lmlxc -lmlx -Wl,-rpath,@executable_path -lc++ -framework Metal -framework Foundation -framework Accelerate -mmacosx-version-min=14.0"
|
MLX_CGO_LDFLAGS="-L$(pwd)/$BUILD_DIR/lib/ollama -lmlxc -lmlx -Wl,-rpath,@executable_path -lc++ -framework Metal -framework Foundation -framework Accelerate -mmacosx-version-min=14.0"
|
||||||
fi
|
fi
|
||||||
GOOS=darwin GOARCH=$ARCH CGO_ENABLED=1 CGO_CFLAGS="$MLX_CGO_CFLAGS" CGO_LDFLAGS="$MLX_CGO_LDFLAGS" go build -tags mlx -o $INSTALL_PREFIX/imagegen ./x/imagegen/cmd/engine
|
GOOS=darwin GOARCH=$ARCH CGO_ENABLED=1 CGO_CFLAGS="$MLX_CGO_CFLAGS" CGO_LDFLAGS="$MLX_CGO_LDFLAGS" go build -tags mlx -o $INSTALL_PREFIX/ollama-mlx .
|
||||||
GOOS=darwin GOARCH=$ARCH CGO_ENABLED=1 go build -o $INSTALL_PREFIX .
|
GOOS=darwin GOARCH=$ARCH CGO_ENABLED=1 go build -o $INSTALL_PREFIX .
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -82,19 +82,19 @@ _sign_darwin() {
|
|||||||
status "Creating universal binary..."
|
status "Creating universal binary..."
|
||||||
mkdir -p dist/darwin
|
mkdir -p dist/darwin
|
||||||
lipo -create -output dist/darwin/ollama dist/darwin-*/ollama
|
lipo -create -output dist/darwin/ollama dist/darwin-*/ollama
|
||||||
lipo -create -output dist/darwin/imagegen dist/darwin-*/imagegen
|
lipo -create -output dist/darwin/ollama-mlx dist/darwin-*/ollama-mlx
|
||||||
chmod +x dist/darwin/ollama
|
chmod +x dist/darwin/ollama
|
||||||
chmod +x dist/darwin/imagegen
|
chmod +x dist/darwin/ollama-mlx
|
||||||
|
|
||||||
if [ -n "$APPLE_IDENTITY" ]; then
|
if [ -n "$APPLE_IDENTITY" ]; then
|
||||||
for F in dist/darwin/ollama dist/darwin-*/lib/ollama/* dist/darwin/imagegen; do
|
for F in dist/darwin/ollama dist/darwin-*/lib/ollama/* dist/darwin/ollama-mlx; do
|
||||||
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime $F
|
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime $F
|
||||||
done
|
done
|
||||||
|
|
||||||
# create a temporary zip for notarization
|
# create a temporary zip for notarization
|
||||||
TEMP=$(mktemp -u).zip
|
TEMP=$(mktemp -u).zip
|
||||||
ditto -c -k --keepParent dist/darwin/ollama "$TEMP"
|
ditto -c -k --keepParent dist/darwin/ollama "$TEMP"
|
||||||
xcrun notarytool submit "$TEMP" --wait --timeout 10m --apple-id $APPLE_ID --password $APPLE_PASSWORD --team-id $APPLE_TEAM_ID
|
xcrun notarytool submit "$TEMP" --wait --timeout 20m --apple-id $APPLE_ID --password $APPLE_PASSWORD --team-id $APPLE_TEAM_ID
|
||||||
rm -f "$TEMP"
|
rm -f "$TEMP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -154,23 +154,25 @@ _build_macapp() {
|
|||||||
mkdir -p dist/Ollama.app/Contents/Resources
|
mkdir -p dist/Ollama.app/Contents/Resources
|
||||||
if [ -d dist/darwin-amd64 ]; then
|
if [ -d dist/darwin-amd64 ]; then
|
||||||
lipo -create -output dist/Ollama.app/Contents/Resources/ollama dist/darwin-amd64/ollama dist/darwin-arm64/ollama
|
lipo -create -output dist/Ollama.app/Contents/Resources/ollama dist/darwin-amd64/ollama dist/darwin-arm64/ollama
|
||||||
lipo -create -output dist/Ollama.app/Contents/Resources/imagegen dist/darwin-amd64/imagegen dist/darwin-arm64/imagegen
|
lipo -create -output dist/Ollama.app/Contents/Resources/ollama-mlx dist/darwin-amd64/ollama-mlx dist/darwin-arm64/ollama-mlx
|
||||||
for F in dist/darwin-amd64/lib/ollama/*mlx*.dylib ; do
|
for F in dist/darwin-amd64/lib/ollama/*mlx*.dylib ; do
|
||||||
lipo -create -output dist/darwin/$(basename $F) $F dist/darwin-arm64/lib/ollama/$(basename $F)
|
lipo -create -output dist/darwin/$(basename $F) $F dist/darwin-arm64/lib/ollama/$(basename $F)
|
||||||
done
|
done
|
||||||
cp dist/darwin-*/lib/ollama/*.so dist/darwin-*/lib/ollama/*.dylib dist/Ollama.app/Contents/Resources/
|
cp dist/darwin-*/lib/ollama/*.so dist/darwin-*/lib/ollama/*.dylib dist/Ollama.app/Contents/Resources/
|
||||||
cp dist/darwin/*.dylib dist/Ollama.app/Contents/Resources/
|
cp dist/darwin/*.dylib dist/Ollama.app/Contents/Resources/
|
||||||
|
# Copy MLX metallib (architecture-independent, just use arm64 version)
|
||||||
|
cp dist/darwin-arm64/lib/ollama/*.metallib dist/Ollama.app/Contents/Resources/ 2>/dev/null || true
|
||||||
else
|
else
|
||||||
cp -a dist/darwin/ollama dist/Ollama.app/Contents/Resources/ollama
|
cp -a dist/darwin/ollama dist/Ollama.app/Contents/Resources/ollama
|
||||||
cp dist/darwin/*.so dist/darwin/*.dylib dist/Ollama.app/Contents/Resources/
|
cp dist/darwin/*.so dist/darwin/*.dylib dist/Ollama.app/Contents/Resources/
|
||||||
fi
|
fi
|
||||||
cp -a dist/darwin/imagegen dist/Ollama.app/Contents/Resources/imagegen
|
cp -a dist/darwin/ollama-mlx dist/Ollama.app/Contents/Resources/ollama-mlx
|
||||||
chmod a+x dist/Ollama.app/Contents/Resources/ollama
|
chmod a+x dist/Ollama.app/Contents/Resources/ollama
|
||||||
|
|
||||||
# Sign
|
# Sign
|
||||||
if [ -n "$APPLE_IDENTITY" ]; then
|
if [ -n "$APPLE_IDENTITY" ]; then
|
||||||
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime dist/Ollama.app/Contents/Resources/ollama
|
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime dist/Ollama.app/Contents/Resources/ollama
|
||||||
for lib in dist/Ollama.app/Contents/Resources/*.so dist/Ollama.app/Contents/Resources/*.dylib dist/Ollama.app/Contents/Resources/imagegen ; do
|
for lib in dist/Ollama.app/Contents/Resources/*.so dist/Ollama.app/Contents/Resources/*.dylib dist/Ollama.app/Contents/Resources/*.metallib dist/Ollama.app/Contents/Resources/ollama-mlx ; do
|
||||||
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime ${lib}
|
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime ${lib}
|
||||||
done
|
done
|
||||||
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier com.electron.ollama --deep --options=runtime dist/Ollama.app
|
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier com.electron.ollama --deep --options=runtime dist/Ollama.app
|
||||||
@@ -178,11 +180,11 @@ _build_macapp() {
|
|||||||
|
|
||||||
rm -f dist/Ollama-darwin.zip
|
rm -f dist/Ollama-darwin.zip
|
||||||
ditto -c -k --keepParent dist/Ollama.app dist/Ollama-darwin.zip
|
ditto -c -k --keepParent dist/Ollama.app dist/Ollama-darwin.zip
|
||||||
(cd dist/Ollama.app/Contents/Resources/; tar -cf - ollama imagegen *.so *.dylib) | gzip -9vc > dist/ollama-darwin.tgz
|
(cd dist/Ollama.app/Contents/Resources/; tar -cf - ollama ollama-mlx *.so *.dylib *.metallib 2>/dev/null) | gzip -9vc > dist/ollama-darwin.tgz
|
||||||
|
|
||||||
# Notarize and Staple
|
# Notarize and Staple
|
||||||
if [ -n "$APPLE_IDENTITY" ]; then
|
if [ -n "$APPLE_IDENTITY" ]; then
|
||||||
$(xcrun -f notarytool) submit dist/Ollama-darwin.zip --wait --timeout 10m --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID"
|
$(xcrun -f notarytool) submit dist/Ollama-darwin.zip --wait --timeout 20m --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID"
|
||||||
rm -f dist/Ollama-darwin.zip
|
rm -f dist/Ollama-darwin.zip
|
||||||
$(xcrun -f stapler) staple dist/Ollama.app
|
$(xcrun -f stapler) staple dist/Ollama.app
|
||||||
ditto -c -k --keepParent dist/Ollama.app dist/Ollama-darwin.zip
|
ditto -c -k --keepParent dist/Ollama.app dist/Ollama-darwin.zip
|
||||||
@@ -206,7 +208,7 @@ _build_macapp() {
|
|||||||
rm -f dist/rw*.dmg
|
rm -f dist/rw*.dmg
|
||||||
|
|
||||||
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime dist/Ollama.dmg
|
codesign -f --timestamp -s "$APPLE_IDENTITY" --identifier ai.ollama.ollama --options=runtime dist/Ollama.dmg
|
||||||
$(xcrun -f notarytool) submit dist/Ollama.dmg --wait --timeout 10m --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID"
|
$(xcrun -f notarytool) submit dist/Ollama.dmg --wait --timeout 20m --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID"
|
||||||
$(xcrun -f stapler) staple dist/Ollama.dmg
|
$(xcrun -f stapler) staple dist/Ollama.dmg
|
||||||
else
|
else
|
||||||
echo "WARNING: Code signing disabled, this bundle will not work for upgrade testing"
|
echo "WARNING: Code signing disabled, this bundle will not work for upgrade testing"
|
||||||
|
|||||||
@@ -48,53 +48,12 @@ if echo $PLATFORM | grep "amd64" > /dev/null; then
|
|||||||
.
|
.
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Deduplicate CUDA libraries across mlx_* and cuda_* directories
|
|
||||||
deduplicate_cuda_libs() {
|
|
||||||
local base_dir="$1"
|
|
||||||
echo "Deduplicating CUDA libraries in ${base_dir}..."
|
|
||||||
|
|
||||||
# Find all mlx_cuda_* directories
|
|
||||||
for mlx_dir in "${base_dir}"/lib/ollama/mlx_cuda_*; do
|
|
||||||
[ -d "${mlx_dir}" ] || continue
|
|
||||||
|
|
||||||
# Extract CUDA version (e.g., v12, v13)
|
|
||||||
cuda_version=$(basename "${mlx_dir}" | sed 's/mlx_cuda_//')
|
|
||||||
cuda_dir="${base_dir}/lib/ollama/cuda_${cuda_version}"
|
|
||||||
|
|
||||||
# Skip if corresponding cuda_* directory doesn't exist
|
|
||||||
[ -d "${cuda_dir}" ] || continue
|
|
||||||
|
|
||||||
echo " Checking ${mlx_dir} against ${cuda_dir}..."
|
|
||||||
|
|
||||||
# Find all .so* files in mlx directory
|
|
||||||
find "${mlx_dir}" -type f -name "*.so*" | while read mlx_file; do
|
|
||||||
filename=$(basename "${mlx_file}")
|
|
||||||
cuda_file="${cuda_dir}/${filename}"
|
|
||||||
|
|
||||||
# Skip if file doesn't exist in cuda directory
|
|
||||||
[ -f "${cuda_file}" ] || continue
|
|
||||||
|
|
||||||
# Compare checksums
|
|
||||||
mlx_sum=$(sha256sum "${mlx_file}" | awk '{print $1}')
|
|
||||||
cuda_sum=$(sha256sum "${cuda_file}" | awk '{print $1}')
|
|
||||||
|
|
||||||
if [ "${mlx_sum}" = "${cuda_sum}" ]; then
|
|
||||||
echo " Deduplicating ${filename}"
|
|
||||||
# Calculate relative path from mlx_dir to cuda_dir
|
|
||||||
rel_path="../cuda_${cuda_version}/${filename}"
|
|
||||||
rm -f "${mlx_file}"
|
|
||||||
ln -s "${rel_path}" "${mlx_file}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run deduplication for each platform output directory
|
# Run deduplication for each platform output directory
|
||||||
if echo $PLATFORM | grep "," > /dev/null ; then
|
if echo $PLATFORM | grep "," > /dev/null ; then
|
||||||
deduplicate_cuda_libs "./dist/linux_amd64"
|
$(dirname $0)/deduplicate_cuda_libs.sh "./dist/linux_amd64"
|
||||||
deduplicate_cuda_libs "./dist/linux_arm64"
|
$(dirname $0)/deduplicate_cuda_libs.sh "./dist/linux_arm64"
|
||||||
elif echo $PLATFORM | grep "amd64\|arm64" > /dev/null ; then
|
elif echo $PLATFORM | grep "amd64\|arm64" > /dev/null ; then
|
||||||
deduplicate_cuda_libs "./dist"
|
$(dirname $0)/deduplicate_cuda_libs.sh "./dist"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# buildx behavior changes for single vs. multiplatform
|
# buildx behavior changes for single vs. multiplatform
|
||||||
|
|||||||
60
scripts/deduplicate_cuda_libs.sh
Executable file
60
scripts/deduplicate_cuda_libs.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# Deduplicate CUDA libraries across mlx_* and cuda_* directories
|
||||||
|
# This script finds identical .so* files in mlx_cuda_* directories that exist
|
||||||
|
# in corresponding cuda_* directories and replaces them with symlinks.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "ERROR: No directory specified" >&2
|
||||||
|
echo "Usage: $0 <base_directory>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
base_dir="$1"
|
||||||
|
|
||||||
|
if [ ! -d "${base_dir}" ]; then
|
||||||
|
echo "ERROR: Directory ${base_dir} does not exist" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deduplicating CUDA libraries in ${base_dir}..."
|
||||||
|
|
||||||
|
# Find all mlx_cuda_* directories
|
||||||
|
for mlx_dir in "${base_dir}"/lib/ollama/mlx_cuda_*; do
|
||||||
|
[ -d "${mlx_dir}" ] || continue
|
||||||
|
|
||||||
|
# Extract CUDA version (e.g., v12, v13)
|
||||||
|
cuda_version=$(basename "${mlx_dir}" | sed 's/mlx_cuda_//')
|
||||||
|
cuda_dir="${base_dir}/lib/ollama/cuda_${cuda_version}"
|
||||||
|
|
||||||
|
# Skip if corresponding cuda_* directory doesn't exist
|
||||||
|
[ -d "${cuda_dir}" ] || continue
|
||||||
|
|
||||||
|
echo " Checking ${mlx_dir} against ${cuda_dir}..."
|
||||||
|
|
||||||
|
# Find all .so* files in mlx directory
|
||||||
|
find "${mlx_dir}" -type f -name "*.so*" | while read mlx_file; do
|
||||||
|
filename=$(basename "${mlx_file}")
|
||||||
|
cuda_file="${cuda_dir}/${filename}"
|
||||||
|
|
||||||
|
# Skip if file doesn't exist in cuda directory
|
||||||
|
[ -f "${cuda_file}" ] || continue
|
||||||
|
|
||||||
|
# Compare checksums
|
||||||
|
mlx_sum=$(sha256sum "${mlx_file}" | awk '{print $1}')
|
||||||
|
cuda_sum=$(sha256sum "${cuda_file}" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ "${mlx_sum}" = "${cuda_sum}" ]; then
|
||||||
|
echo " Deduplicating ${filename}"
|
||||||
|
# Calculate relative path from mlx_dir to cuda_dir
|
||||||
|
rel_path="../cuda_${cuda_version}/${filename}"
|
||||||
|
rm -f "${mlx_file}"
|
||||||
|
ln -s "${rel_path}" "${mlx_file}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Deduplication complete"
|
||||||
@@ -95,11 +95,48 @@ func (p *blobDownloadPart) UnmarshalJSON(b []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// numDownloadParts is the default number of concurrent download parts for standard downloads
|
||||||
numDownloadParts = 16
|
numDownloadParts = 16
|
||||||
|
// numHFDownloadParts is the reduced number of concurrent download parts for HuggingFace
|
||||||
|
// downloads to avoid triggering rate limits (HTTP 429 errors). See GitHub issue #13297.
|
||||||
|
numHFDownloadParts = 4
|
||||||
minDownloadPartSize int64 = 100 * format.MegaByte
|
minDownloadPartSize int64 = 100 * format.MegaByte
|
||||||
maxDownloadPartSize int64 = 1000 * format.MegaByte
|
maxDownloadPartSize int64 = 1000 * format.MegaByte
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isHuggingFaceURL returns true if the URL is from a HuggingFace domain.
|
||||||
|
// This includes:
|
||||||
|
// - huggingface.co (main domain)
|
||||||
|
// - *.huggingface.co (subdomains like cdn-lfs.huggingface.co)
|
||||||
|
// - hf.co (shortlink domain)
|
||||||
|
// - *.hf.co (CDN domains like cdn-lfs.hf.co, cdn-lfs3.hf.co)
|
||||||
|
func isHuggingFaceURL(u *url.URL) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
return host == "huggingface.co" ||
|
||||||
|
strings.HasSuffix(host, ".huggingface.co") ||
|
||||||
|
host == "hf.co" ||
|
||||||
|
strings.HasSuffix(host, ".hf.co")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNumDownloadParts returns the number of concurrent download parts to use
|
||||||
|
// for the given URL. HuggingFace URLs use reduced concurrency (default 4) to
|
||||||
|
// avoid triggering rate limits. This can be overridden via the OLLAMA_HF_CONCURRENCY
|
||||||
|
// environment variable. For non-HuggingFace URLs, returns the standard concurrency (16).
|
||||||
|
func getNumDownloadParts(u *url.URL) int {
|
||||||
|
if isHuggingFaceURL(u) {
|
||||||
|
if v := os.Getenv("OLLAMA_HF_CONCURRENCY"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return numHFDownloadParts
|
||||||
|
}
|
||||||
|
return numDownloadParts
|
||||||
|
}
|
||||||
|
|
||||||
func (p *blobDownloadPart) Name() string {
|
func (p *blobDownloadPart) Name() string {
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
p.blobDownload.Name, "partial", strconv.Itoa(p.N),
|
p.blobDownload.Name, "partial", strconv.Itoa(p.N),
|
||||||
@@ -271,7 +308,11 @@ func (b *blobDownload) run(ctx context.Context, requestURL *url.URL, opts *regis
|
|||||||
}
|
}
|
||||||
|
|
||||||
g, inner := errgroup.WithContext(ctx)
|
g, inner := errgroup.WithContext(ctx)
|
||||||
g.SetLimit(numDownloadParts)
|
concurrency := getNumDownloadParts(directURL)
|
||||||
|
if concurrency != numDownloadParts {
|
||||||
|
slog.Info(fmt.Sprintf("using reduced concurrency (%d) for HuggingFace download", concurrency))
|
||||||
|
}
|
||||||
|
g.SetLimit(concurrency)
|
||||||
for i := range b.Parts {
|
for i := range b.Parts {
|
||||||
part := b.Parts[i]
|
part := b.Parts[i]
|
||||||
if part.Completed.Load() == part.Size {
|
if part.Completed.Load() == part.Size {
|
||||||
|
|||||||
194
server/download_test.go
Normal file
194
server/download_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsHuggingFaceURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil url",
|
||||||
|
url: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface.co main domain",
|
||||||
|
url: "https://huggingface.co/some/model",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cdn-lfs.huggingface.co subdomain",
|
||||||
|
url: "https://cdn-lfs.huggingface.co/repos/abc/123",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cdn-lfs3.hf.co CDN domain",
|
||||||
|
url: "https://cdn-lfs3.hf.co/repos/abc/123",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hf.co shortlink domain",
|
||||||
|
url: "https://hf.co/model",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase HuggingFace domain",
|
||||||
|
url: "https://HUGGINGFACE.CO/model",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case HF domain",
|
||||||
|
url: "https://Cdn-Lfs.HF.Co/repos",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ollama registry",
|
||||||
|
url: "https://registry.ollama.ai/v2/library/llama3",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github.com",
|
||||||
|
url: "https://github.com/ollama/ollama",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fake huggingface domain",
|
||||||
|
url: "https://nothuggingface.co/model",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fake hf domain",
|
||||||
|
url: "https://nothf.co/model",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface in path not host",
|
||||||
|
url: "https://example.com/huggingface.co/model",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var u *url.URL
|
||||||
|
if tc.url != "" {
|
||||||
|
var err error
|
||||||
|
u, err = url.Parse(tc.url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse URL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := isHuggingFaceURL(u)
|
||||||
|
assert.Equal(t, tc.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNumDownloadParts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
envValue string
|
||||||
|
expected int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil url returns default",
|
||||||
|
url: "",
|
||||||
|
envValue: "",
|
||||||
|
expected: numDownloadParts,
|
||||||
|
description: "nil URL should return standard concurrency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ollama registry returns default",
|
||||||
|
url: "https://registry.ollama.ai/v2/library/llama3",
|
||||||
|
envValue: "",
|
||||||
|
expected: numDownloadParts,
|
||||||
|
description: "Ollama registry should use standard concurrency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface returns reduced default",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "",
|
||||||
|
expected: numHFDownloadParts,
|
||||||
|
description: "HuggingFace should use reduced concurrency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hf.co CDN returns reduced default",
|
||||||
|
url: "https://cdn-lfs3.hf.co/repos/abc/123",
|
||||||
|
envValue: "",
|
||||||
|
expected: numHFDownloadParts,
|
||||||
|
description: "HuggingFace CDN should use reduced concurrency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface with env override",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "2",
|
||||||
|
expected: 2,
|
||||||
|
description: "OLLAMA_HF_CONCURRENCY should override default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface with higher env override",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "8",
|
||||||
|
expected: 8,
|
||||||
|
description: "OLLAMA_HF_CONCURRENCY can be set higher than default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface with invalid env (non-numeric)",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "invalid",
|
||||||
|
expected: numHFDownloadParts,
|
||||||
|
description: "Invalid OLLAMA_HF_CONCURRENCY should fall back to default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface with invalid env (zero)",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "0",
|
||||||
|
expected: numHFDownloadParts,
|
||||||
|
description: "Zero OLLAMA_HF_CONCURRENCY should fall back to default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "huggingface with invalid env (negative)",
|
||||||
|
url: "https://huggingface.co/model/repo",
|
||||||
|
envValue: "-1",
|
||||||
|
expected: numHFDownloadParts,
|
||||||
|
description: "Negative OLLAMA_HF_CONCURRENCY should fall back to default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-huggingface ignores env",
|
||||||
|
url: "https://registry.ollama.ai/v2/library/llama3",
|
||||||
|
envValue: "2",
|
||||||
|
expected: numDownloadParts,
|
||||||
|
description: "OLLAMA_HF_CONCURRENCY should not affect non-HF URLs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set or clear the environment variable
|
||||||
|
if tc.envValue != "" {
|
||||||
|
t.Setenv("OLLAMA_HF_CONCURRENCY", tc.envValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var u *url.URL
|
||||||
|
if tc.url != "" {
|
||||||
|
var err error
|
||||||
|
u, err = url.Parse(tc.url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse URL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getNumDownloadParts(u)
|
||||||
|
assert.Equal(t, tc.expected, got, tc.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
x/README.md
44
x/README.md
@@ -4,21 +4,47 @@
|
|||||||
|
|
||||||
We're working on a new experimental backend based on the [MLX project](https://github.com/ml-explore/mlx)
|
We're working on a new experimental backend based on the [MLX project](https://github.com/ml-explore/mlx)
|
||||||
|
|
||||||
Support is currently limited to MacOS and Linux with CUDA GPUs. We're looking to add support for Windows CUDA soon, and other GPU vendors. To build:
|
Support is currently limited to MacOS and Linux with CUDA GPUs. We're looking to add support for Windows CUDA soon, and other GPU vendors.
|
||||||
|
|
||||||
```
|
### Building ollama-mlx
|
||||||
|
|
||||||
|
The `ollama-mlx` binary is a separate build of Ollama with MLX support enabled. This enables experimental features like image generation.
|
||||||
|
|
||||||
|
#### macOS (Apple Silicon and Intel)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build MLX backend libraries
|
||||||
cmake --preset MLX
|
cmake --preset MLX
|
||||||
cmake --build --preset MLX --parallel
|
cmake --build --preset MLX --parallel
|
||||||
cmake --install build --component MLX
|
cmake --install build --component MLX
|
||||||
go build -tags mlx .
|
|
||||||
|
# Build ollama-mlx binary
|
||||||
|
go build -tags mlx -o ollama-mlx .
|
||||||
```
|
```
|
||||||
|
|
||||||
On linux, use the preset "MLX CUDA 13" or "MLX CUDA 12" to enable CUDA with the default Ollama NVIDIA GPU architectures enabled.
|
#### Linux (CUDA)
|
||||||
|
|
||||||
|
On Linux, use the preset "MLX CUDA 13" or "MLX CUDA 12" to enable CUDA with the default Ollama NVIDIA GPU architectures enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build MLX backend libraries with CUDA support
|
||||||
|
cmake --preset 'MLX CUDA 13'
|
||||||
|
cmake --build --preset 'MLX CUDA 13' --parallel
|
||||||
|
cmake --install build --component MLX
|
||||||
|
|
||||||
|
# Build ollama-mlx binary
|
||||||
|
CGO_CFLAGS="-O3 -I$(pwd)/build/_deps/mlx-c-src" \
|
||||||
|
CGO_LDFLAGS="-L$(pwd)/build/lib/ollama -lmlxc -lmlx" \
|
||||||
|
go build -tags mlx -o ollama-mlx .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using build scripts
|
||||||
|
|
||||||
|
The build scripts automatically create the `ollama-mlx` binary:
|
||||||
|
|
||||||
|
- **macOS**: `./scripts/build_darwin.sh` produces `dist/darwin/ollama-mlx`
|
||||||
|
- **Linux**: `./scripts/build_linux.sh` produces `ollama-mlx` in the output archives
|
||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
Based on the experimental MLX backend, we're working on adding imagegen support. After running the cmake commands above:
|
Image generation is built into the `ollama-mlx` binary. Run `ollama-mlx serve` to start the server with image generation support enabled.
|
||||||
|
|
||||||
```
|
|
||||||
go build -o imagegen ./x/imagegen/cmd/engine
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -123,11 +123,6 @@ func RegisterFlags(cmd *cobra.Command) {
|
|||||||
// Returns true if it handled the request, false if the caller should continue with normal flow.
|
// Returns true if it handled the request, false if the caller should continue with normal flow.
|
||||||
// Supports flags: --width, --height, --steps, --seed, --negative
|
// Supports flags: --width, --height, --steps, --seed, --negative
|
||||||
func RunCLI(cmd *cobra.Command, name string, prompt string, interactive bool, keepAlive *api.Duration) error {
|
func RunCLI(cmd *cobra.Command, name string, prompt string, interactive bool, keepAlive *api.Duration) error {
|
||||||
// Verify it's a valid image gen model
|
|
||||||
if ResolveModelName(name) == "" {
|
|
||||||
return fmt.Errorf("unknown image generation model: %s", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get options from flags (with env var defaults)
|
// Get options from flags (with env var defaults)
|
||||||
opts := DefaultOptions()
|
opts := DefaultOptions()
|
||||||
if cmd != nil && cmd.Flags() != nil {
|
if cmd != nil && cmd.Flags() != nil {
|
||||||
@@ -511,10 +506,7 @@ func displayImageInTerminal(imagePath string) bool {
|
|||||||
// Send in chunks for large images
|
// Send in chunks for large images
|
||||||
const chunkSize = 4096
|
const chunkSize = 4096
|
||||||
for i := 0; i < len(encoded); i += chunkSize {
|
for i := 0; i < len(encoded); i += chunkSize {
|
||||||
end := i + chunkSize
|
end := min(i+chunkSize, len(encoded))
|
||||||
if end > len(encoded) {
|
|
||||||
end = len(encoded)
|
|
||||||
}
|
|
||||||
chunk := encoded[i:end]
|
chunk := encoded[i:end]
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ func NewServer(modelName string) (*Server, error) {
|
|||||||
port = rand.Intn(65535-49152) + 49152
|
port = rand.Intn(65535-49152) + 49152
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the ollama executable path
|
// Get the ollama-mlx executable path (in same directory as current executable)
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to lookup executable path: %w", err)
|
return nil, fmt.Errorf("unable to lookup executable path: %w", err)
|
||||||
@@ -78,11 +80,42 @@ func NewServer(modelName string) (*Server, error) {
|
|||||||
if eval, err := filepath.EvalSymlinks(exe); err == nil {
|
if eval, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
exe = eval
|
exe = eval
|
||||||
}
|
}
|
||||||
|
mlxExe := filepath.Join(filepath.Dir(exe), "ollama-mlx")
|
||||||
|
|
||||||
// Spawn subprocess: ollama runner --image-engine --model <path> --port <port>
|
// Spawn subprocess: ollama-mlx runner --image-engine --model <path> --port <port>
|
||||||
cmd := exec.Command(exe, "runner", "--image-engine", "--model", modelName, "--port", strconv.Itoa(port))
|
cmd := exec.Command(mlxExe, "runner", "--image-engine", "--model", modelName, "--port", strconv.Itoa(port))
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
|
|
||||||
|
// On Linux, set LD_LIBRARY_PATH to include MLX library directories
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
// Build library paths: start with LibOllamaPath, then add any mlx_* subdirectories
|
||||||
|
libraryPaths := []string{ml.LibOllamaPath}
|
||||||
|
if mlxDirs, err := filepath.Glob(filepath.Join(ml.LibOllamaPath, "mlx_*")); err == nil {
|
||||||
|
libraryPaths = append(libraryPaths, mlxDirs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append existing LD_LIBRARY_PATH if set
|
||||||
|
if existingPath, ok := os.LookupEnv("LD_LIBRARY_PATH"); ok {
|
||||||
|
libraryPaths = append(libraryPaths, filepath.SplitList(existingPath)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
pathEnvVal := strings.Join(libraryPaths, string(filepath.ListSeparator))
|
||||||
|
|
||||||
|
// Update or add LD_LIBRARY_PATH in cmd.Env
|
||||||
|
found := false
|
||||||
|
for i := range cmd.Env {
|
||||||
|
if strings.HasPrefix(cmd.Env[i], "LD_LIBRARY_PATH=") {
|
||||||
|
cmd.Env[i] = "LD_LIBRARY_PATH=" + pathEnvVal
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cmd.Env = append(cmd.Env, "LD_LIBRARY_PATH="+pathEnvVal)
|
||||||
|
}
|
||||||
|
slog.Debug("mlx subprocess library path", "LD_LIBRARY_PATH", pathEnvVal)
|
||||||
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
port: port,
|
port: port,
|
||||||
@@ -113,7 +146,7 @@ func NewServer(modelName string) (*Server, error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
slog.Info("starting image runner subprocess", "model", modelName, "port", port)
|
slog.Info("starting ollama-mlx image runner subprocess", "exe", mlxExe, "model", modelName, "port", port)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to start image runner: %w", err)
|
return nil, fmt.Errorf("failed to start image runner: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user