mirror of
https://github.com/ollama/ollama.git
synced 2026-03-27 02:58:43 +07:00
495 lines
14 KiB
Go
495 lines
14 KiB
Go
package launch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
"github.com/ollama/ollama/cmd/config"
|
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
|
internalcloud "github.com/ollama/ollama/internal/cloud"
|
|
"github.com/ollama/ollama/internal/modelref"
|
|
"github.com/ollama/ollama/progress"
|
|
)
|
|
|
|
var recommendedModels = []ModelItem{
|
|
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
|
|
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
|
|
{Name: "glm-5:cloud", Description: "Reasoning and code generation", Recommended: true},
|
|
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
|
|
{Name: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true},
|
|
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
|
|
}
|
|
|
|
var recommendedVRAM = map[string]string{
|
|
"glm-4.7-flash": "~25GB",
|
|
"qwen3.5": "~11GB",
|
|
}
|
|
|
|
// cloudModelLimit holds context and output token limits for a cloud model.
|
|
type cloudModelLimit struct {
|
|
Context int
|
|
Output int
|
|
}
|
|
|
|
// cloudModelLimits maps cloud model base names to their token limits.
|
|
// TODO(parthsareen): grab context/output limits from model info instead of hardcoding
|
|
var cloudModelLimits = map[string]cloudModelLimit{
|
|
"minimax-m2.7": {Context: 204_800, Output: 128_000},
|
|
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
|
|
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
|
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
|
|
"glm-4.6": {Context: 202_752, Output: 131_072},
|
|
"glm-4.7": {Context: 202_752, Output: 131_072},
|
|
"glm-5": {Context: 202_752, Output: 131_072},
|
|
"gpt-oss:120b": {Context: 131_072, Output: 131_072},
|
|
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
|
|
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
|
|
"kimi-k2.5": {Context: 262_144, Output: 262_144},
|
|
"kimi-k2-thinking": {Context: 262_144, Output: 262_144},
|
|
"nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072},
|
|
"qwen3-coder:480b": {Context: 262_144, Output: 65_536},
|
|
"qwen3-coder-next": {Context: 262_144, Output: 32_768},
|
|
"qwen3-next:80b": {Context: 262_144, Output: 32_768},
|
|
"qwen3.5": {Context: 262_144, Output: 32_768},
|
|
}
|
|
|
|
// lookupCloudModelLimit returns the token limits for a cloud model.
|
|
// It normalizes explicit cloud source suffixes before checking the shared limit map.
|
|
func lookupCloudModelLimit(name string) (cloudModelLimit, bool) {
|
|
base, stripped := modelref.StripCloudSourceTag(name)
|
|
if stripped {
|
|
if l, ok := cloudModelLimits[base]; ok {
|
|
return l, true
|
|
}
|
|
}
|
|
return cloudModelLimit{}, false
|
|
}
|
|
|
|
// missingModelPolicy controls how model-not-found errors should be handled.
|
|
type missingModelPolicy int
|
|
|
|
const (
|
|
// missingModelPromptPull prompts the user to download missing local models.
|
|
missingModelPromptPull missingModelPolicy = iota
|
|
// missingModelAutoPull downloads missing local models without prompting.
|
|
missingModelAutoPull
|
|
// missingModelFail returns an error for missing local models without prompting.
|
|
missingModelFail
|
|
)
|
|
|
|
// OpenBrowser opens the URL in the user's browser.
|
|
func OpenBrowser(url string) {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
_ = exec.Command("open", url).Start()
|
|
case "linux":
|
|
// Skip on headless systems where no display server is available
|
|
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
|
return
|
|
}
|
|
_ = exec.Command("xdg-open", url).Start()
|
|
case "windows":
|
|
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
}
|
|
}
|
|
|
|
// ensureAuth ensures the user is signed in before cloud-backed models run.
|
|
func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]bool, selected []string) error {
|
|
var selectedCloudModels []string
|
|
for _, m := range selected {
|
|
if cloudModels[m] {
|
|
selectedCloudModels = append(selectedCloudModels, m)
|
|
}
|
|
}
|
|
if len(selectedCloudModels) == 0 {
|
|
return nil
|
|
}
|
|
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
|
|
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
modelList := strings.Join(selectedCloudModels, ", ")
|
|
|
|
if DefaultSignIn != nil {
|
|
_, err := DefaultSignIn(modelList, aErr.SigninURL)
|
|
if errors.Is(err, ErrCancelled) {
|
|
return ErrCancelled
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s requires sign in", modelList)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
yes, err := ConfirmPrompt(fmt.Sprintf("sign in to use %s?", modelList))
|
|
if errors.Is(err, ErrCancelled) {
|
|
return ErrCancelled
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
return ErrCancelled
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL)
|
|
OpenBrowser(aErr.SigninURL)
|
|
|
|
spinnerFrames := []string{"|", "/", "-", "\\"}
|
|
frame := 0
|
|
fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[0])
|
|
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Fprintf(os.Stderr, "\r\033[K")
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
frame++
|
|
fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
|
|
|
|
if frame%10 == 0 {
|
|
u, err := client.Whoami(ctx)
|
|
if err == nil && u != nil && u.Name != "" {
|
|
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// showOrPullWithPolicy checks if a model exists and applies the provided missing-model policy.
|
|
func showOrPullWithPolicy(ctx context.Context, client *api.Client, model string, policy missingModelPolicy, isCloudModel bool) error {
|
|
if _, err := client.Show(ctx, &api.ShowRequest{Model: model}); err == nil {
|
|
return nil
|
|
} else {
|
|
var statusErr api.StatusError
|
|
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if isCloudModel {
|
|
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
|
|
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
|
|
}
|
|
return fmt.Errorf("model %q not found", model)
|
|
}
|
|
|
|
switch policy {
|
|
case missingModelAutoPull:
|
|
return pullMissingModel(ctx, client, model)
|
|
case missingModelFail:
|
|
return fmt.Errorf("model %q not found; run 'ollama pull %s' first, or use --yes to auto-pull", model, model)
|
|
default:
|
|
return confirmAndPull(ctx, client, model)
|
|
}
|
|
}
|
|
|
|
func confirmAndPull(ctx context.Context, client *api.Client, model string) error {
|
|
if ok, err := ConfirmPrompt(fmt.Sprintf("Download %s?", model)); err != nil {
|
|
return err
|
|
} else if !ok {
|
|
return errCancelled
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
return pullMissingModel(ctx, client, model)
|
|
}
|
|
|
|
func pullMissingModel(ctx context.Context, client *api.Client, model string) error {
|
|
if err := pullModel(ctx, client, model, false); err != nil {
|
|
return fmt.Errorf("failed to pull %s: %w", model, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prepareEditorIntegration persists models and applies editor-managed config files.
|
|
func prepareEditorIntegration(name string, runner Runner, editor Editor, models []string) error {
|
|
if ok, err := confirmEditorEdit(runner, editor); err != nil {
|
|
return err
|
|
} else if !ok {
|
|
return errCancelled
|
|
}
|
|
if err := editor.Edit(models); err != nil {
|
|
return fmt.Errorf("setup failed: %w", err)
|
|
}
|
|
if err := config.SaveIntegration(name, models); err != nil {
|
|
return fmt.Errorf("failed to save: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func confirmEditorEdit(runner Runner, editor Editor) (bool, error) {
|
|
paths := editor.Paths()
|
|
if len(paths) == 0 {
|
|
return true, nil
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", runner)
|
|
for _, path := range paths {
|
|
fmt.Fprintf(os.Stderr, " %s\n", path)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", fileutil.BackupDir())
|
|
|
|
return ConfirmPrompt("Proceed?")
|
|
}
|
|
|
|
// buildModelList merges existing models with recommendations for selection UIs.
|
|
func buildModelList(existing []modelInfo, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) {
|
|
existingModels = make(map[string]bool)
|
|
cloudModels = make(map[string]bool)
|
|
recommended := make(map[string]bool)
|
|
var hasLocalModel, hasCloudModel bool
|
|
|
|
recDesc := make(map[string]string)
|
|
for _, rec := range recommendedModels {
|
|
recommended[rec.Name] = true
|
|
recDesc[rec.Name] = rec.Description
|
|
}
|
|
|
|
for _, m := range existing {
|
|
existingModels[m.Name] = true
|
|
if m.Remote {
|
|
cloudModels[m.Name] = true
|
|
hasCloudModel = true
|
|
} else {
|
|
hasLocalModel = true
|
|
}
|
|
displayName := strings.TrimSuffix(m.Name, ":latest")
|
|
existingModels[displayName] = true
|
|
item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}
|
|
items = append(items, item)
|
|
}
|
|
|
|
for _, rec := range recommendedModels {
|
|
if existingModels[rec.Name] || existingModels[rec.Name+":latest"] {
|
|
continue
|
|
}
|
|
items = append(items, rec)
|
|
if isCloudModelName(rec.Name) {
|
|
cloudModels[rec.Name] = true
|
|
}
|
|
}
|
|
|
|
checked := make(map[string]bool, len(preChecked))
|
|
for _, n := range preChecked {
|
|
checked[n] = true
|
|
}
|
|
|
|
if current != "" {
|
|
matchedCurrent := false
|
|
for _, item := range items {
|
|
if item.Name == current {
|
|
current = item.Name
|
|
matchedCurrent = true
|
|
break
|
|
}
|
|
}
|
|
if !matchedCurrent {
|
|
for _, item := range items {
|
|
if strings.HasPrefix(item.Name, current+":") {
|
|
current = item.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if checked[current] {
|
|
preChecked = append([]string{current}, slices.DeleteFunc(preChecked, func(m string) bool { return m == current })...)
|
|
}
|
|
|
|
notInstalled := make(map[string]bool)
|
|
for i := range items {
|
|
if !existingModels[items[i].Name] && !cloudModels[items[i].Name] {
|
|
notInstalled[items[i].Name] = true
|
|
var parts []string
|
|
if items[i].Description != "" {
|
|
parts = append(parts, items[i].Description)
|
|
}
|
|
if vram := recommendedVRAM[items[i].Name]; vram != "" {
|
|
parts = append(parts, vram)
|
|
}
|
|
parts = append(parts, "(not downloaded)")
|
|
items[i].Description = strings.Join(parts, ", ")
|
|
}
|
|
}
|
|
|
|
recRank := make(map[string]int)
|
|
for i, rec := range recommendedModels {
|
|
recRank[rec.Name] = i + 1
|
|
}
|
|
|
|
onlyLocal := hasLocalModel && !hasCloudModel
|
|
|
|
if hasLocalModel || hasCloudModel {
|
|
slices.SortStableFunc(items, func(a, b ModelItem) int {
|
|
ac, bc := checked[a.Name], checked[b.Name]
|
|
aNew, bNew := notInstalled[a.Name], notInstalled[b.Name]
|
|
aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0
|
|
aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name]
|
|
|
|
if ac != bc {
|
|
if ac {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if aRec != bRec {
|
|
if aRec {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if aRec && bRec {
|
|
if aCloud != bCloud {
|
|
if onlyLocal {
|
|
if aCloud {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
if aCloud {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return recRank[a.Name] - recRank[b.Name]
|
|
}
|
|
if aNew != bNew {
|
|
if aNew {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
|
})
|
|
}
|
|
|
|
return items, preChecked, existingModels, cloudModels
|
|
}
|
|
|
|
// isCloudModelName reports whether the model name has an explicit cloud source.
|
|
func isCloudModelName(name string) bool {
|
|
return modelref.HasExplicitCloudSource(name)
|
|
}
|
|
|
|
// filterCloudModels drops remote-only models from the given inventory.
|
|
func filterCloudModels(existing []modelInfo) []modelInfo {
|
|
filtered := existing[:0]
|
|
for _, m := range existing {
|
|
if !m.Remote {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// filterCloudItems removes cloud models from selection items.
|
|
func filterCloudItems(items []ModelItem) []ModelItem {
|
|
filtered := items[:0]
|
|
for _, item := range items {
|
|
if !isCloudModelName(item.Name) {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func isCloudModel(ctx context.Context, client *api.Client, name string) bool {
|
|
if client == nil {
|
|
return false
|
|
}
|
|
resp, err := client.Show(ctx, &api.ShowRequest{Model: name})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return resp.RemoteModel != ""
|
|
}
|
|
|
|
// cloudStatusDisabled returns whether cloud usage is currently disabled.
|
|
func cloudStatusDisabled(ctx context.Context, client *api.Client) (disabled bool, known bool) {
|
|
status, err := client.CloudStatusExperimental(ctx)
|
|
if err != nil {
|
|
var statusErr api.StatusError
|
|
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
|
|
return false, false
|
|
}
|
|
return false, false
|
|
}
|
|
return status.Cloud.Disabled, true
|
|
}
|
|
|
|
// TODO(parthsareen): this duplicates the pull progress UI in cmd.PullHandler.
|
|
// Move the shared pull rendering to a small utility once the package boundary settles.
|
|
func pullModel(ctx context.Context, client *api.Client, model string, insecure bool) error {
|
|
p := progress.NewProgress(os.Stderr)
|
|
defer p.Stop()
|
|
|
|
bars := make(map[string]*progress.Bar)
|
|
var status string
|
|
var spinner *progress.Spinner
|
|
|
|
fn := func(resp api.ProgressResponse) error {
|
|
if resp.Digest != "" {
|
|
if resp.Completed == 0 {
|
|
return nil
|
|
}
|
|
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
}
|
|
|
|
bar, ok := bars[resp.Digest]
|
|
if !ok {
|
|
name, isDigest := strings.CutPrefix(resp.Digest, "sha256:")
|
|
name = strings.TrimSpace(name)
|
|
if isDigest {
|
|
name = name[:min(12, len(name))]
|
|
}
|
|
bar = progress.NewBar(fmt.Sprintf("pulling %s:", name), resp.Total, resp.Completed)
|
|
bars[resp.Digest] = bar
|
|
p.Add(resp.Digest, bar)
|
|
}
|
|
|
|
bar.Set(resp.Completed)
|
|
} else if status != resp.Status {
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
}
|
|
|
|
status = resp.Status
|
|
spinner = progress.NewSpinner(status)
|
|
p.Add(status, spinner)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
request := api.PullRequest{Name: model, Insecure: insecure}
|
|
return client.Pull(ctx, &request, fn)
|
|
}
|