mirror of
https://github.com/ollama/ollama.git
synced 2026-04-18 00:54:05 +02:00
397 lines
10 KiB
Go
397 lines
10 KiB
Go
package launch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
|
"github.com/ollama/ollama/envconfig"
|
|
"github.com/ollama/ollama/types/model"
|
|
)
|
|
|
|
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
|
type Pi struct{}
|
|
|
|
const (
|
|
piNpmPackage = "@mariozechner/pi-coding-agent"
|
|
piWebSearchSource = "npm:@ollama/pi-web-search"
|
|
piWebSearchPkg = "@ollama/pi-web-search"
|
|
)
|
|
|
|
func (p *Pi) String() string { return "Pi" }
|
|
|
|
func (p *Pi) Run(model string, args []string) error {
|
|
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
|
if err := ensureNpmInstalled(); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sChecking Pi installation...%s\n", ansiGray, ansiReset)
|
|
bin, err := ensurePiInstalled()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ensurePiWebSearchPackage(bin)
|
|
|
|
fmt.Fprintf(os.Stderr, "\n%sLaunching Pi...%s\n\n", ansiGray, ansiReset)
|
|
|
|
cmd := exec.Command(bin, args...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func ensureNpmInstalled() error {
|
|
if _, err := exec.LookPath("npm"); err != nil {
|
|
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensurePiInstalled() (string, error) {
|
|
if _, err := exec.LookPath("pi"); err == nil {
|
|
return "pi", nil
|
|
}
|
|
|
|
if _, err := exec.LookPath("npm"); err != nil {
|
|
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/")
|
|
}
|
|
|
|
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ok {
|
|
return "", fmt.Errorf("pi installation cancelled")
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
|
|
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("failed to install pi: %w", err)
|
|
}
|
|
|
|
if _, err := exec.LookPath("pi"); err != nil {
|
|
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
|
|
return "pi", nil
|
|
}
|
|
|
|
func ensurePiWebSearchPackage(bin string) {
|
|
if !shouldManagePiWebSearch() {
|
|
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
|
|
|
|
installed, err := piPackageInstalled(bin, piWebSearchSource)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
|
return
|
|
}
|
|
|
|
if !installed {
|
|
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
|
cmd := exec.Command(bin, "install", piWebSearchSource)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s Warning: could not install %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%s ✓ Installed %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
|
cmd := exec.Command(bin, "update", piWebSearchSource)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
|
}
|
|
|
|
func shouldManagePiWebSearch() bool {
|
|
client, err := api.ClientFromEnvironment()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
disabled, known := cloudStatusDisabled(context.Background(), client)
|
|
if known && disabled {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func piPackageInstalled(bin, source string) (bool, error) {
|
|
cmd := exec.Command(bin, "list")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if msg == "" {
|
|
return false, err
|
|
}
|
|
return false, fmt.Errorf("%w: %s", err, msg)
|
|
}
|
|
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, source) {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (p *Pi) Paths() []string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var paths []string
|
|
modelsPath := filepath.Join(home, ".pi", "agent", "models.json")
|
|
if _, err := os.Stat(modelsPath); err == nil {
|
|
paths = append(paths, modelsPath)
|
|
}
|
|
settingsPath := filepath.Join(home, ".pi", "agent", "settings.json")
|
|
if _, err := os.Stat(settingsPath); err == nil {
|
|
paths = append(paths, settingsPath)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func (p *Pi) Edit(models []string) error {
|
|
if len(models) == 0 {
|
|
return nil
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
configPath := filepath.Join(home, ".pi", "agent", "models.json")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
config := make(map[string]any)
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
_ = json.Unmarshal(data, &config)
|
|
}
|
|
|
|
providers, ok := config["providers"].(map[string]any)
|
|
if !ok {
|
|
providers = make(map[string]any)
|
|
}
|
|
|
|
ollama, ok := providers["ollama"].(map[string]any)
|
|
if !ok {
|
|
ollama = map[string]any{
|
|
"baseUrl": envconfig.Host().String() + "/v1",
|
|
"api": "openai-completions",
|
|
"apiKey": "ollama",
|
|
}
|
|
}
|
|
|
|
existingModels, ok := ollama["models"].([]any)
|
|
if !ok {
|
|
existingModels = make([]any, 0)
|
|
}
|
|
|
|
// Build set of selected models to track which need to be added
|
|
selectedSet := make(map[string]bool, len(models))
|
|
for _, m := range models {
|
|
selectedSet[m] = true
|
|
}
|
|
|
|
// Build new models list:
|
|
// 1. Keep user-managed models (no _launch marker) - untouched
|
|
// 2. Keep ollama-managed models (_launch marker) that are still selected,
|
|
// except stale cloud entries that should be rebuilt below
|
|
// 3. Add new ollama-managed models
|
|
var newModels []any
|
|
for _, m := range existingModels {
|
|
if modelObj, ok := m.(map[string]any); ok {
|
|
if id, ok := modelObj["id"].(string); ok {
|
|
// User-managed model (no _launch marker) - always preserve
|
|
if !isPiOllamaModel(modelObj) {
|
|
newModels = append(newModels, m)
|
|
} else if selectedSet[id] {
|
|
// Rebuild stale managed cloud entries so createConfig refreshes
|
|
// the whole entry instead of patching it in place.
|
|
if !hasContextWindow(modelObj) {
|
|
if _, ok := lookupCloudModelLimit(id); ok {
|
|
continue
|
|
}
|
|
}
|
|
newModels = append(newModels, m)
|
|
selectedSet[id] = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add newly selected models that weren't already in the list
|
|
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
|
ctx := context.Background()
|
|
for _, model := range models {
|
|
if selectedSet[model] {
|
|
newModels = append(newModels, createConfig(ctx, client, model))
|
|
}
|
|
}
|
|
|
|
ollama["models"] = newModels
|
|
providers["ollama"] = ollama
|
|
config["providers"] = providers
|
|
|
|
configData, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fileutil.WriteWithBackup(configPath, configData); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update settings.json with default provider and model
|
|
settingsPath := filepath.Join(home, ".pi", "agent", "settings.json")
|
|
settings := make(map[string]any)
|
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
|
_ = json.Unmarshal(data, &settings)
|
|
}
|
|
|
|
settings["defaultProvider"] = "ollama"
|
|
settings["defaultModel"] = models[0]
|
|
|
|
settingsData, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fileutil.WriteWithBackup(settingsPath, settingsData)
|
|
}
|
|
|
|
func (p *Pi) Models() []string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
configPath := filepath.Join(home, ".pi", "agent", "models.json")
|
|
config, err := fileutil.ReadJSON(configPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
providers, _ := config["providers"].(map[string]any)
|
|
ollama, _ := providers["ollama"].(map[string]any)
|
|
models, _ := ollama["models"].([]any)
|
|
|
|
var result []string
|
|
for _, m := range models {
|
|
if modelObj, ok := m.(map[string]any); ok {
|
|
if id, ok := modelObj["id"].(string); ok {
|
|
result = append(result, id)
|
|
}
|
|
}
|
|
}
|
|
slices.Sort(result)
|
|
return result
|
|
}
|
|
|
|
// isPiOllamaModel reports whether a model config entry is managed by ollama launch
|
|
func isPiOllamaModel(cfg map[string]any) bool {
|
|
if v, ok := cfg["_launch"].(bool); ok && v {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasContextWindow(cfg map[string]any) bool {
|
|
switch v := cfg["contextWindow"].(type) {
|
|
case float64:
|
|
return v > 0
|
|
case int:
|
|
return v > 0
|
|
case int64:
|
|
return v > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// createConfig builds Pi model config with capability detection
|
|
func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any {
|
|
cfg := map[string]any{
|
|
"id": modelID,
|
|
"_launch": true,
|
|
}
|
|
if l, ok := lookupCloudModelLimit(modelID); ok {
|
|
cfg["contextWindow"] = l.Context
|
|
}
|
|
|
|
applyCloudContextFallback := func() {
|
|
if l, ok := lookupCloudModelLimit(modelID); ok {
|
|
cfg["contextWindow"] = l.Context
|
|
}
|
|
}
|
|
|
|
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID})
|
|
if err != nil {
|
|
applyCloudContextFallback()
|
|
return cfg
|
|
}
|
|
|
|
// Set input types based on vision capability
|
|
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
|
cfg["input"] = []string{"text", "image"}
|
|
} else {
|
|
cfg["input"] = []string{"text"}
|
|
}
|
|
|
|
// Set reasoning based on thinking capability
|
|
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
|
cfg["reasoning"] = true
|
|
}
|
|
|
|
// Extract context window from ModelInfo. For known cloud models, the
|
|
// pre-filled shared limit remains unless the server provides a positive value.
|
|
hasContextWindow := false
|
|
for key, val := range resp.ModelInfo {
|
|
if strings.HasSuffix(key, ".context_length") {
|
|
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
|
cfg["contextWindow"] = int(ctxLen)
|
|
hasContextWindow = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !hasContextWindow {
|
|
applyCloudContextFallback()
|
|
}
|
|
|
|
return cfg
|
|
}
|