diff --git a/cmd/launch/claude.go b/cmd/launch/claude.go index da3904576..cab90bb11 100644 --- a/cmd/launch/claude.go +++ b/cmd/launch/claude.go @@ -44,7 +44,7 @@ func (c *Claude) findPath() (string, error) { return fallback, nil } -func (c *Claude) Run(model string, args []string) error { +func (c *Claude) Run(model string, _ []LaunchModel, args []string) error { claudePath, err := c.findPath() if err != nil { return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart") diff --git a/cmd/launch/claude_desktop.go b/cmd/launch/claude_desktop.go index 953f40767..1ef8182c9 100644 --- a/cmd/launch/claude_desktop.go +++ b/cmd/launch/claude_desktop.go @@ -130,7 +130,7 @@ func (c *ClaudeDesktop) SkipModelReadiness() bool { return true } -func (c *ClaudeDesktop) Run(_ string, _ []string) error { +func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error { return errClaudeDesktopUnsupported() } diff --git a/cmd/launch/claude_desktop_test.go b/cmd/launch/claude_desktop_test.go index f4eae641d..ab20d3ce7 100644 --- a/cmd/launch/claude_desktop_test.go +++ b/cmd/launch/claude_desktop_test.go @@ -932,7 +932,7 @@ func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) { ) for _, args := range [][]string{nil, {"--foo"}} { - err := (&ClaudeDesktop{}).Run("qwen3.5", args) + err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args) if err == nil { t.Fatal("expected Run to fail") } diff --git a/cmd/launch/cline.go b/cmd/launch/cline.go index 68957b509..4a6c74233 100644 --- a/cmd/launch/cline.go +++ b/cmd/launch/cline.go @@ -16,7 +16,7 @@ type Cline struct{} func (c *Cline) String() string { return "Cline" } -func (c *Cline) Run(model string, args []string) error { +func (c *Cline) Run(model string, _ []LaunchModel, args []string) error { if _, err := exec.LookPath("cline"); err != nil { return fmt.Errorf("cline is not installed, install with: npm install -g cline") } @@ -40,7 +40,7 @@ func (c *Cline) Paths() []string { return nil } -func (c *Cline) Edit(models []string) error { +func (c *Cline) Edit(models []LaunchModel) error { if len(models) == 0 { return nil } @@ -66,10 +66,10 @@ func (c *Cline) Edit(models []string) error { baseURL := envconfig.Host().String() config["ollamaBaseUrl"] = baseURL config["actModeApiProvider"] = "ollama" - config["actModeOllamaModelId"] = models[0] + config["actModeOllamaModelId"] = models[0].Name config["actModeOllamaBaseUrl"] = baseURL config["planModeApiProvider"] = "ollama" - config["planModeOllamaModelId"] = models[0] + config["planModeOllamaModelId"] = models[0].Name config["planModeOllamaBaseUrl"] = baseURL config["welcomeViewCompleted"] = true diff --git a/cmd/launch/cline_test.go b/cmd/launch/cline_test.go index f2440a27f..9dabca3ae 100644 --- a/cmd/launch/cline_test.go +++ b/cmd/launch/cline_test.go @@ -43,7 +43,7 @@ func TestClineEdit(t *testing.T) { t.Run("creates config from scratch", func(t *testing.T) { os.RemoveAll(filepath.Join(tmpDir, ".cline")) - if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil { + if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil { t.Fatal(err) } @@ -77,7 +77,7 @@ func TestClineEdit(t *testing.T) { data, _ := json.Marshal(existing) os.WriteFile(configPath, data, 0o644) - if err := c.Edit([]string{"glm-5:cloud"}); err != nil { + if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil { t.Fatal(err) } @@ -93,10 +93,10 @@ func TestClineEdit(t *testing.T) { t.Run("updates model on re-edit", func(t *testing.T) { os.RemoveAll(filepath.Join(tmpDir, ".cline")) - if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil { + if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil { t.Fatal(err) } - if err := c.Edit([]string{"glm-5:cloud"}); err != nil { + if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestClineEdit(t *testing.T) { t.Run("uses first model as primary", func(t *testing.T) { os.RemoveAll(filepath.Join(tmpDir, ".cline")) - if err := c.Edit([]string{"kimi-k2.5:cloud", "glm-5:cloud"}); err != nil { + if err := c.Edit(testLaunchModels("kimi-k2.5:cloud", "glm-5:cloud")); err != nil { t.Fatal(err) } diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 203822695..ec4b5aee8 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -1,18 +1,13 @@ package launch import ( - "context" "encoding/json" "fmt" - "net/http" "os" "os/exec" "path/filepath" - "slices" - "strconv" "strings" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/types/model" @@ -48,12 +43,12 @@ func (c *Codex) args(model, modelCatalogPath string, extra []string) []string { return args } -func (c *Codex) Run(model string, args []string) error { +func (c *Codex) Run(model string, models []LaunchModel, args []string) error { if err := checkCodexVersion(); err != nil { return err } - if err := ensureCodexConfig(model); err != nil { + if err := ensureCodexConfig(model, models); err != nil { return fmt.Errorf("failed to configure codex: %w", err) } @@ -74,7 +69,7 @@ func (c *Codex) Run(model string, args []string) error { // ensureCodexConfig writes a Codex profile and model catalog so Codex uses the // local Ollama server and has model metadata available. -func ensureCodexConfig(modelName string) error { +func ensureCodexConfig(modelName string, models []LaunchModel) error { configPath, err := codexConfigPath() if err != nil { return err @@ -86,7 +81,7 @@ func ensureCodexConfig(modelName string) error { } catalogPath := codexModelCatalogPathForConfig(configPath) - if err := writeCodexModelCatalog(catalogPath, modelName); err != nil { + if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil { return err } @@ -577,8 +572,15 @@ func codexRootLineHasKey(line, key string) bool { return ok } -func writeCodexModelCatalog(catalogPath, modelName string) error { - entry := buildCodexModelEntry(modelName) +func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel { + if model, ok := findLaunchModel(models, modelName); ok { + return model.WithCloudLimits() + } + return fallbackLaunchModel(modelName) +} + +func writeCodexModelCatalog(catalogPath string, model LaunchModel) error { + entry := buildCodexModelEntry(model) catalog := map[string]any{ "models": []any{entry}, @@ -592,40 +594,28 @@ func writeCodexModelCatalog(catalogPath, modelName string) error { return os.WriteFile(catalogPath, data, 0o644) } -func buildCodexModelEntry(modelName string) map[string]any { +func buildCodexModelEntry(launchModel LaunchModel) map[string]any { + modelName := launchModel.Name contextWindow := codexFallbackContextWindow - hasVision := false systemPrompt := "" + if launchModel.ContextLength > 0 { + contextWindow = launchModel.ContextLength + } else if launchModel.Details.ContextLength > 0 { + contextWindow = launchModel.Details.ContextLength + } if l, ok := lookupCloudModelLimit(modelName); ok { contextWindow = l.Context } - client := api.NewClient(envconfig.Host(), http.DefaultClient) - resp, err := client.Show(context.Background(), &api.ShowRequest{Model: modelName}) - if err == nil { - systemPrompt = resp.System - if slices.Contains(resp.Capabilities, model.CapabilityVision) { - hasVision = true - } - - if !isCloudModelName(modelName) { - if n, ok := modelInfoContextLength(resp.ModelInfo); ok { - contextWindow = n - } - if resp.Details.Format != "safetensors" { - if ctxLen := envconfig.ContextLength(); ctxLen > 0 { - contextWindow = int(ctxLen) - } - if numCtx := parseNumCtx(resp.Parameters); numCtx > 0 { - contextWindow = numCtx - } - } + if !isCloudModelName(modelName) && launchModel.Details.Format != "safetensors" { + if ctxLen := envconfig.ContextLength(); ctxLen > 0 { + contextWindow = int(ctxLen) } } modalities := []string{"text"} - if hasVision { + if launchModel.HasCapability(model.CapabilityVision) { modalities = append(modalities, "image") } @@ -654,19 +644,6 @@ func buildCodexModelEntry(modelName string) map[string]any { } } -func parseNumCtx(parameters string) int { - for _, line := range strings.Split(parameters, "\n") { - fields := strings.Fields(line) - if len(fields) == 2 && fields[0] == "num_ctx" { - if v, err := strconv.ParseFloat(fields[1], 64); err == nil { - return int(v) - } - } - } - - return 0 -} - func checkCodexVersion() error { if _, err := exec.LookPath("codex"); err != nil { return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex") diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index 0ec3a45a8..ba1d308e2 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -1,24 +1,18 @@ package launch import ( - "context" "encoding/json" "fmt" - "net/http" "os" "os/exec" "path/filepath" "runtime" - "slices" "strconv" "strings" "time" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/config" "github.com/ollama/ollama/cmd/internal/fileutil" - "github.com/ollama/ollama/envconfig" - modelpkg "github.com/ollama/ollama/types/model" ) const ( @@ -68,10 +62,10 @@ func (c *CodexApp) Paths() []string { } func (c *CodexApp) Configure(model string) error { - return c.ConfigureWithModels(model, []string{model}) + return c.ConfigureWithModels(model, launchModelsFromNames([]string{model})) } -func (c *CodexApp) ConfigureWithModels(primary string, models []string) error { +func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) error { primary = strings.TrimSpace(primary) if primary == "" { return fmt.Errorf("codex-app requires a model") @@ -88,7 +82,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []string) error { if err != nil { return err } - if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModelNames(primary, models)); err != nil { + if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModels(primary, models)); err != nil { return err } return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ @@ -202,7 +196,7 @@ func (c *CodexApp) RestoreSuccessMessage() string { return codexAppRestoreSuccess } -func (c *CodexApp) Run(_ string, args []string) error { +func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error { if err := codexAppSupported(); err != nil { return err } @@ -308,23 +302,15 @@ func codexAppModelCatalogPathForConfig(configPath string) string { return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename) } -func writeCodexAppModelCatalog(path, primary string, models []string) error { +func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error { if len(models) == 0 { return fmt.Errorf("codex-app model catalog cannot be empty") } - client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() baseInstructions := codexAppBaseInstructions() - primaryMetadata := codexAppSelectedModelMetadata(ctx, client, primary) entries := make([]map[string]any, 0, len(models)) for i, model := range models { - metadata := codexAppDefaultModelMetadata() - if model == primary { - metadata = primaryMetadata - } - entries = append(entries, codexAppCatalogEntry(model, metadata, i, baseInstructions)) + entries = append(entries, codexAppCatalogEntry(model.Name, codexAppModelMetadataFromLaunchModel(model), i, baseInstructions)) } data, err := json.MarshalIndent(map[string]any{"models": entries}, "", " ") @@ -337,32 +323,26 @@ func writeCodexAppModelCatalog(path, primary string, models []string) error { return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName) } -func codexAppCatalogModelNames(primary string, fallback []string) []string { - models := codexAppTagModelNames() - if len(models) == 0 { - models = fallback - } - return dedupeModelList(append([]string{primary}, models...)) -} - -func codexAppTagModelNames() []string { - client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - resp, err := client.List(ctx) - if err != nil { - return nil - } - - models := make([]string, 0, len(resp.Models)) - for _, model := range resp.Models { - name := strings.TrimSpace(model.Name) - if name != "" { - models = append(models, name) +func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel { + seen := make(map[string]bool, len(models)+1) + out := make([]LaunchModel, 0, len(models)+1) + add := func(model LaunchModel) { + if model.Name == "" || seen[model.Name] { + return } + seen[model.Name] = true + out = append(out, model) } - return models + + if model, ok := findLaunchModel(models, primary); ok { + add(model) + } else { + add(fallbackLaunchModel(primary)) + } + for _, model := range models { + add(model) + } + return out } type codexAppModelMetadata struct { @@ -377,16 +357,12 @@ func codexAppDefaultModelMetadata() codexAppModelMetadata { } } -func codexAppSelectedModelMetadata(ctx context.Context, client *api.Client, model string) codexAppModelMetadata { +func codexAppModelMetadataFromLaunchModel(model LaunchModel) codexAppModelMetadata { metadata := codexAppDefaultModelMetadata() - resp, err := client.Show(ctx, &api.ShowRequest{Model: model}) - if err != nil { - return metadata + if model.ContextLength > 0 { + metadata.contextWindow = model.ContextLength } - if n, ok := modelInfoContextLength(resp.ModelInfo); ok { - metadata.contextWindow = n - } - if slices.Contains(resp.Capabilities, modelpkg.CapabilityVision) { + if model.HasCapability("vision") { metadata.inputModalities = []string{"text", "image"} } return metadata diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index b2bf242ff..187a06f56 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -3,8 +3,6 @@ package launch import ( "encoding/json" "fmt" - "net/http" - "net/http/httptest" "os" "path/filepath" "strings" @@ -12,6 +10,7 @@ import ( "time" "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/types/model" ) func withCodexAppPlatform(t *testing.T, goos string) { @@ -177,7 +176,7 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) { } c := &CodexApp{} - if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2", "qwen3:8b"}); err != nil { + if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2", "qwen3:8b")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -262,7 +261,7 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes t.Fatal(err) } - if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -294,7 +293,7 @@ func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testin setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434") - if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -331,7 +330,7 @@ func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) { t.Fatal(err) } - err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}) + err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) } @@ -374,7 +373,7 @@ func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *te t.Fatal(err) } - err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}) + err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) } @@ -532,32 +531,16 @@ func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) { } } -func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { +func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) - showCalls := make(map[string]int) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"},{"name":"llama3.2"}]}`) - case "/api/show": - var req struct { - Model string `json:"model"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Fatalf("decode show request: %v", err) - } - showCalls[req.Model]++ - fmt.Fprintf(w, `{"model_info":{"general.context_length":%d},"capabilities":["vision"]}`, 65536+len(req.Model)) - default: - http.NotFound(w, r) - } - })) - defer srv.Close() - t.Setenv("OLLAMA_HOST", srv.URL) - - if err := (&CodexApp{}).ConfigureWithModels("gemma4", []string{"fallback"}); err != nil { + models := []LaunchModel{ + {Name: "gemma4", ContextLength: 65536 + len("gemma4"), Capabilities: []model.Capability{model.CapabilityVision}}, + {Name: "qwen3:8b"}, + {Name: "llama3.2"}, + } + if err := (&CodexApp{}).ConfigureWithModels("gemma4", models); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -577,7 +560,7 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { } if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" { - t.Fatalf("catalog slugs = %v, want /api/tags models", got) + t.Fatalf("catalog slugs = %v, want enriched models", got) } for _, model := range catalog.Models { slug, _ := model["slug"].(string) @@ -596,11 +579,9 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { } wantContext := float64(128000) wantModalities := []string{"text"} - wantShowCalls := 0 if slug == "gemma4" { wantContext = float64(65536 + len(slug)) wantModalities = []string{"text", "image"} - wantShowCalls = 1 } if model["context_window"] != wantContext { t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext) @@ -608,9 +589,6 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") { t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities) } - if showCalls[slug] != wantShowCalls { - t.Fatalf("show calls for %q = %d, want %d", slug, showCalls[slug], wantShowCalls) - } } } @@ -638,7 +616,7 @@ func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) { t.Fatal(err) } - if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -688,7 +666,7 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { } c := &CodexApp{} - if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } if err := c.Restore(); err != nil { @@ -772,7 +750,7 @@ func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) { t.Fatal(err) } - if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("ConfigureWithModels returned error: %v", err) } @@ -815,7 +793,7 @@ func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testi t.Fatal(err) } - if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil { t.Fatalf("first ConfigureWithModels returned error: %v", err) } @@ -829,7 +807,7 @@ func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testi t.Fatal(err) } - if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", []string{"qwen3:8b"}); err != nil { + if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { t.Fatalf("second ConfigureWithModels returned error: %v", err) } if err := (&CodexApp{}).Restore(); err != nil { @@ -1125,7 +1103,7 @@ func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) { }, ) - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 || openCalls != 1 { @@ -1163,7 +1141,7 @@ func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) { }, ) - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 || openCalls != 1 { @@ -1199,7 +1177,7 @@ func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) { return nil } - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } want := []string{"quit", "force", "open"} @@ -1226,7 +1204,7 @@ func TestCodexAppRunReturnsMacForceStopError(t *testing.T) { return fmt.Errorf("operation not permitted") } - err := (&CodexApp{}).Run("qwen3.5", nil) + err := (&CodexApp{}).Run("qwen3.5", nil, nil) if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "operation not permitted") { t.Fatalf("Run error = %v, want force stop failure", err) } @@ -1245,7 +1223,7 @@ func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) { }, ) - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if openCalls != 1 { @@ -1287,7 +1265,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) { return nil } - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if quitCalls != 1 { @@ -1333,7 +1311,7 @@ func TestCodexAppRunForceStopsWindowsBackgroundProcessesBeforeReopening(t *testi return nil } - if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } want := []string{"quit", "force", "open:OpenAI.Codex_2p2nqsd0c76g0!App"} @@ -1368,7 +1346,7 @@ func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) { return nil } - err := (&CodexApp{}).Run("qwen3.5", nil) + err := (&CodexApp{}).Run("qwen3.5", nil, nil) if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "access denied") { t.Fatalf("Run error = %v, want force stop failure", err) } @@ -1376,7 +1354,7 @@ func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) { func TestCodexAppRunRejectsExtraArgs(t *testing.T) { withCodexAppPlatform(t, "darwin") - err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"}) + err := (&CodexApp{}).Run("qwen3.5", nil, []string{"--foo"}) if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") { t.Fatalf("Run error = %v, want extra args rejection", err) } diff --git a/cmd/launch/codex_test.go b/cmd/launch/codex_test.go index 7641280c5..3d59e4bf4 100644 --- a/cmd/launch/codex_test.go +++ b/cmd/launch/codex_test.go @@ -3,15 +3,15 @@ package launch import ( "encoding/json" "fmt" - "net/http" - "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" + "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" + modelpkg "github.com/ollama/ollama/types/model" ) func TestCodexArgs(t *testing.T) { @@ -369,7 +369,7 @@ func TestEnsureCodexConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) - if err := ensureCodexConfig("llama3.2"); err != nil { + if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil { t.Fatal(err) } @@ -401,10 +401,10 @@ func TestEnsureCodexConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) - if err := ensureCodexConfig("llama3.2"); err != nil { + if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil { t.Fatal(err) } - if err := ensureCodexConfig("llama3.2"); err != nil { + if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil { t.Fatal(err) } @@ -439,29 +439,6 @@ func assertBackupContains(t *testing.T, pattern, marker string) { t.Fatalf("backup matching %q with marker %q not found", pattern, marker) } -func TestParseNumCtx(t *testing.T) { - tests := []struct { - name string - parameters string - want int - }{ - {"num_ctx set", "num_ctx 8192", 8192}, - {"num_ctx with other params", "temperature 0.7\nnum_ctx 4096\ntop_p 0.9", 4096}, - {"no num_ctx", "temperature 0.7\ntop_p 0.9", 0}, - {"empty string", "", 0}, - {"malformed value", "num_ctx abc", 0}, - {"float value", "num_ctx 8192.0", 8192}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := parseNumCtx(tt.parameters); got != tt.want { - t.Errorf("parseNumCtx(%q) = %d, want %d", tt.parameters, got, tt.want) - } - }) - } -} - func TestModelInfoContextLength(t *testing.T) { tests := []struct { name string @@ -488,124 +465,91 @@ func TestModelInfoContextLength(t *testing.T) { func TestBuildCodexModelEntryContextWindow(t *testing.T) { tests := []struct { name string - modelName string - showResponse string + model LaunchModel envContextLen string wantContext int }{ { - name: "architectural context length as fallback", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "details": {"format": "gguf"} - }`, + name: "inventory context length as fallback", + model: LaunchModel{ + Name: "llama3.2", + ContextLength: 131072, + Details: api.ModelDetails{Format: "gguf"}, + }, wantContext: 131072, }, { - name: "OLLAMA_CONTEXT_LENGTH overrides architectural", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "details": {"format": "gguf"} - }`, + name: "details context length is used when model context is empty", + model: LaunchModel{ + Name: "llama3.2", + Details: api.ModelDetails{Format: "gguf", ContextLength: 131072}, + }, + wantContext: 131072, + }, + { + name: "OLLAMA_CONTEXT_LENGTH overrides local gguf inventory context", + model: LaunchModel{ + Name: "llama3.2", + ContextLength: 131072, + Details: api.ModelDetails{Format: "gguf"}, + }, envContextLen: "64000", wantContext: 64000, }, { - name: "num_ctx overrides OLLAMA_CONTEXT_LENGTH", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "parameters": "num_ctx 8192", - "details": {"format": "gguf"} - }`, - envContextLen: "64000", - wantContext: 8192, - }, - { - name: "num_ctx overrides architectural", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "parameters": "num_ctx 32768", - "details": {"format": "gguf"} - }`, - wantContext: 32768, - }, - { - name: "safetensors uses architectural context only", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "parameters": "num_ctx 8192", - "details": {"format": "safetensors"} - }`, + name: "safetensors uses inventory context only", + model: LaunchModel{ + Name: "llama3.2", + ContextLength: 131072, + Details: api.ModelDetails{Format: "safetensors"}, + }, envContextLen: "64000", wantContext: 131072, }, { - name: "cloud model uses hardcoded limits", - modelName: "qwen3.5:cloud", - showResponse: `{ - "model_info": {"qwen3_5_moe.context_length": 131072}, - "details": {"format": "gguf"} - }`, + name: "cloud model uses hardcoded limits", + model: LaunchModel{ + Name: "qwen3.5:cloud", + ContextLength: 131072, + Details: api.ModelDetails{Format: "gguf"}, + }, envContextLen: "64000", wantContext: 262144, }, { - name: "unknown cloud model uses fallback context", - modelName: "deepseek-v4-pro:cloud", - showResponse: `{ - "model_info": {"deepseek.context_length": 131072}, - "details": {"format": "gguf"} - }`, + name: "unknown cloud model without metadata uses fallback context", + model: LaunchModel{ + Name: "deepseek-v4-pro:cloud", + }, envContextLen: "64000", wantContext: codexFallbackContextWindow, }, { - name: "vision capability without reasoning advertisement", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "details": {"format": "gguf"}, - "capabilities": ["vision", "thinking"] - }`, + name: "vision capability without reasoning advertisement", + model: LaunchModel{ + Name: "llama3.2", + ContextLength: 131072, + Details: api.ModelDetails{Format: "gguf"}, + Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision, modelpkg.CapabilityThinking}, + }, wantContext: 131072, }, { - name: "system prompt passed through", - modelName: "llama3.2", - showResponse: `{ - "model_info": {"llama.context_length": 131072}, - "details": {"format": "gguf"}, - "system": "You are a helpful assistant." - }`, - wantContext: 131072, + name: "missing metadata uses fallback context", + model: LaunchModel{Name: "llama3.2"}, + wantContext: codexFallbackContextWindow, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/show": - fmt.Fprint(w, tt.showResponse) - default: - http.NotFound(w, r) - } - })) - defer srv.Close() - t.Setenv("OLLAMA_HOST", srv.URL) - if tt.envContextLen != "" { t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen) } else { t.Setenv("OLLAMA_CONTEXT_LENGTH", "") } - entry := buildCodexModelEntry(tt.modelName) + entry := buildCodexModelEntry(tt.model) gotContext, _ := entry["context_window"].(int) if gotContext != tt.wantContext { @@ -626,12 +570,6 @@ func TestBuildCodexModelEntryContextWindow(t *testing.T) { } } - if tt.name == "system prompt passed through" { - if got, _ := entry["base_instructions"].(string); got != "You are a helpful assistant." { - t.Errorf("base_instructions = %q, want %q", got, "You are a helpful assistant.") - } - } - if tt.name == "cloud model uses hardcoded limits" { truncationPolicy, _ := entry["truncation_policy"].(map[string]any) if mode, _ := truncationPolicy["mode"].(string); mode != "tokens" { diff --git a/cmd/launch/copilot.go b/cmd/launch/copilot.go index d626027d3..9914cbb84 100644 --- a/cmd/launch/copilot.go +++ b/cmd/launch/copilot.go @@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) { return fallback, nil } -func (c *Copilot) Run(model string, args []string) error { +func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error { copilotPath, err := c.findPath() if err != nil { return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli") diff --git a/cmd/launch/droid.go b/cmd/launch/droid.go index 45ba215b5..d28c93372 100644 --- a/cmd/launch/droid.go +++ b/cmd/launch/droid.go @@ -40,7 +40,7 @@ type modelEntry struct { func (d *Droid) String() string { return "Droid" } -func (d *Droid) Run(model string, args []string) error { +func (d *Droid) Run(model string, _ []LaunchModel, args []string) error { if _, err := exec.LookPath("droid"); err != nil { return fmt.Errorf("droid is not installed, install from https://docs.factory.ai/cli/getting-started/quickstart") } @@ -64,7 +64,7 @@ func (d *Droid) Paths() []string { return nil } -func (d *Droid) Edit(models []string) error { +func (d *Droid) Edit(models []LaunchModel) error { if len(models) == 0 { return nil } @@ -99,7 +99,7 @@ func (d *Droid) Edit(models []string) error { return fileutil.WriteWithBackup(settingsPath, data, "droid") } -func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []string) map[string]any { +func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []LaunchModel) map[string]any { // Keep only non-Ollama models from the raw map (preserves extra fields) // Rebuild Ollama models var nonOllamaModels []any @@ -119,20 +119,18 @@ func updateDroidSettings(settingsMap map[string]any, settings droidSettings, mod var defaultModelID string for i, model := range models { maxOutput := 64000 - if isCloudModelName(model) { - if l, ok := lookupCloudModelLimit(model); ok { - maxOutput = l.Output - } + if model.MaxOutputTokens > 0 { + maxOutput = model.MaxOutputTokens } - modelID := fmt.Sprintf("custom:%s-%d", model, i) + modelID := fmt.Sprintf("custom:%s-%d", model.Name, i) newModels = append(newModels, modelEntry{ - Model: model, - DisplayName: model, + Model: model.Name, + DisplayName: model.Name, BaseURL: envconfig.Host().String() + "/v1", APIKey: "ollama", Provider: "generic-chat-completion-api", MaxOutputTokens: maxOutput, - SupportsImages: false, + SupportsImages: model.HasCapability("vision"), ID: modelID, Index: i, }) diff --git a/cmd/launch/droid_test.go b/cmd/launch/droid_test.go index e0804fee8..b3a6b58f6 100644 --- a/cmd/launch/droid_test.go +++ b/cmd/launch/droid_test.go @@ -63,7 +63,7 @@ func TestDroidEdit(t *testing.T) { t.Run("fresh install creates models with sequential indices", func(t *testing.T) { cleanup() - if err := d.Edit([]string{"model-a", "model-b"}); err != nil { + if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil { t.Fatal(err) } @@ -99,7 +99,7 @@ func TestDroidEdit(t *testing.T) { t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) { cleanup() - if err := d.Edit([]string{"model-a", "model-b"}); err != nil { + if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil { t.Fatal(err) } @@ -116,10 +116,10 @@ func TestDroidEdit(t *testing.T) { t.Run("re-indexes when models removed", func(t *testing.T) { cleanup() // Add three models - d.Edit([]string{"model-a", "model-b", "model-c"}) + d.Edit(testLaunchModels("model-a", "model-b", "model-c")) // Remove middle model - d.Edit([]string{"model-a", "model-c"}) + d.Edit(testLaunchModels("model-a", "model-c")) settings := readSettings() models := getCustomModels(settings) @@ -155,7 +155,7 @@ func TestDroidEdit(t *testing.T) { ] }`), 0o644) - d.Edit([]string{"model-a"}) + d.Edit(testLaunchModels("model-a")) settings := readSettings() models := getCustomModels(settings) @@ -184,7 +184,7 @@ func TestDroidEdit(t *testing.T) { "sessionDefaultSettings": {"autonomyMode": "auto-high"} }`), 0o644) - d.Edit([]string{"model-a"}) + d.Edit(testLaunchModels("model-a")) settings := readSettings() @@ -203,7 +203,7 @@ func TestDroidEdit(t *testing.T) { t.Run("required fields present", func(t *testing.T) { cleanup() - d.Edit([]string{"test-model"}) + d.Edit(testLaunchModels("test-model")) settings := readSettings() models := getCustomModels(settings) @@ -239,7 +239,7 @@ func TestDroidEdit(t *testing.T) { "sessionDefaultSettings": {"reasoningEffort": "off"} }`), 0o644) - d.Edit([]string{"model-a"}) + d.Edit(testLaunchModels("model-a")) settings := readSettings() session := settings["sessionDefaultSettings"].(map[string]any) @@ -256,7 +256,7 @@ func TestDroidEdit(t *testing.T) { "sessionDefaultSettings": {"reasoningEffort": "high"} }`), 0o644) - d.Edit([]string{"model-a"}) + d.Edit(testLaunchModels("model-a")) settings := readSettings() session := settings["sessionDefaultSettings"].(map[string]any) @@ -281,7 +281,7 @@ func TestDroidEdit_CorruptedJSON(t *testing.T) { os.WriteFile(settingsPath, []byte(`{corrupted json content`), 0o644) // Corrupted JSON should return an error so user knows something is wrong - err := d.Edit([]string{"model-a"}) + err := d.Edit(testLaunchModels("model-a")) if err == nil { t.Fatal("expected error for corrupted JSON, got nil") } @@ -306,7 +306,7 @@ func TestDroidEdit_WrongTypeCustomModels(t *testing.T) { os.WriteFile(settingsPath, []byte(`{"customModels": "not an array"}`), 0o644) // Should not panic - wrong type should be handled gracefully - err := d.Edit([]string{"model-a"}) + err := d.Edit(testLaunchModels("model-a")) if err != nil { t.Fatalf("Edit failed with wrong type customModels: %v", err) } @@ -338,7 +338,7 @@ func TestDroidEdit_EmptyModels(t *testing.T) { os.WriteFile(settingsPath, []byte(originalContent), 0o644) // Empty models should be no-op - err := d.Edit([]string{}) + err := d.Edit(testLaunchModels()) if err != nil { t.Fatalf("Edit with empty models failed: %v", err) } @@ -359,7 +359,7 @@ func TestDroidEdit_DuplicateModels(t *testing.T) { settingsPath := filepath.Join(settingsDir, "settings.json") // Add same model twice - err := d.Edit([]string{"model-a", "model-a"}) + err := d.Edit(testLaunchModels("model-a", "model-a")) if err != nil { t.Fatalf("Edit with duplicates failed: %v", err) } @@ -388,7 +388,7 @@ func TestDroidEdit_MalformedModelEntry(t *testing.T) { // Model entry is a string instead of a map os.WriteFile(settingsPath, []byte(`{"customModels": ["not a map", 123]}`), 0o644) - err := d.Edit([]string{"model-a"}) + err := d.Edit(testLaunchModels("model-a")) if err != nil { t.Fatalf("Edit with malformed entries failed: %v", err) } @@ -415,7 +415,7 @@ func TestDroidEdit_WrongTypeSessionSettings(t *testing.T) { // sessionDefaultSettings is a string instead of map os.WriteFile(settingsPath, []byte(`{"sessionDefaultSettings": "not a map"}`), 0o644) - err := d.Edit([]string{"model-a"}) + err := d.Edit(testLaunchModels("model-a")) if err != nil { t.Fatalf("Edit with wrong type sessionDefaultSettings failed: %v", err) } @@ -490,7 +490,7 @@ func TestDroidEdit_RoundTrip(t *testing.T) { os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644) // Edit with new models - if err := d.Edit([]string{"llama3", "mistral"}); err != nil { + if err := d.Edit(testLaunchModels("llama3", "mistral")); err != nil { t.Fatal(err) } @@ -615,7 +615,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -660,7 +660,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"llama3"}); err != nil { + if err := d.Edit(testLaunchModels("llama3")); err != nil { t.Fatal(err) } @@ -715,10 +715,10 @@ func TestDroidEdit_Idempotent(t *testing.T) { os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644) // Edit twice with same models - d.Edit([]string{"llama3", "mistral"}) + d.Edit(testLaunchModels("llama3", "mistral")) firstData, _ := os.ReadFile(settingsPath) - d.Edit([]string{"llama3", "mistral"}) + d.Edit(testLaunchModels("llama3", "mistral")) secondData, _ := os.ReadFile(settingsPath) // Results should be identical @@ -744,7 +744,7 @@ func TestDroidEdit_MultipleConsecutiveEdits(t *testing.T) { if i%2 == 0 { models = []string{"model-x", "model-y", "model-z"} } - if err := d.Edit(models); err != nil { + if err := d.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("edit %d failed: %v", i, err) } } @@ -803,7 +803,7 @@ func TestDroidEdit_UnicodeAndSpecialCharacters(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -845,7 +845,7 @@ func TestDroidEdit_LargeNumbers(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -889,7 +889,7 @@ func TestDroidEdit_EmptyAndNullValues(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -943,7 +943,7 @@ func TestDroidEdit_DeeplyNestedStructures(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -988,7 +988,7 @@ func TestDroidEdit_ModelNamesWithSpecialCharacters(t *testing.T) { "model_with_underscores", } - if err := d.Edit(specialModels); err != nil { + if err := d.Edit(launchModelsFromNames(specialModels)); err != nil { t.Fatal(err) } @@ -1025,7 +1025,7 @@ func TestDroidEdit_MissingCustomModelsKey(t *testing.T) { t.Fatal(err) } - settings = updateDroidSettings(settings, settingsStruct, []string{"model-a"}) + settings = updateDroidSettings(settings, settingsStruct, testLaunchModels("model-a")) // Original fields preserved if settings["diffMode"] != "github" { @@ -1062,7 +1062,7 @@ func TestDroidEdit_NullCustomModels(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -1090,7 +1090,7 @@ func TestDroidEdit_MinifiedJSON(t *testing.T) { original := `{"diffMode":"github","enableHooks":true,"hooks":{"imported":["cmd1","cmd2"]},"customModels":[],"sessionDefaultSettings":{}}` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -1120,7 +1120,7 @@ func TestDroidEdit_CreatesDirectoryIfMissing(t *testing.T) { t.Fatal("directory should not exist before test") } - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -1157,7 +1157,7 @@ func TestDroidEdit_PreservesFileAfterError(t *testing.T) { os.WriteFile(settingsPath, []byte(original), 0o644) // Empty models list is a no-op, should not modify file - d.Edit([]string{}) + d.Edit(testLaunchModels()) data, _ := os.ReadFile(settingsPath) if string(data) != original { @@ -1181,7 +1181,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) { original := fmt.Sprintf(`{"diffMode": "%s", "customModels": [], "sessionDefaultSettings": {}}`, uniqueMarker) os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -1231,7 +1231,7 @@ func TestDroidEdit_LargeNumberOfModels(t *testing.T) { models = append(models, fmt.Sprintf("model-%d", i)) } - if err := d.Edit(models); err != nil { + if err := d.Edit(launchModelsFromNames(models)); err != nil { t.Fatal(err) } @@ -1261,7 +1261,7 @@ func TestDroidEdit_LocalModelDefaultMaxOutput(t *testing.T) { settingsDir := filepath.Join(tmpDir, ".factory") settingsPath := filepath.Join(settingsDir, "settings.json") - if err := d.Edit([]string{"llama3.2"}); err != nil { + if err := d.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -1312,7 +1312,7 @@ func TestDroidEdit_ArraysWithMixedTypes(t *testing.T) { }` os.WriteFile(settingsPath, []byte(original), 0o644) - if err := d.Edit([]string{"model-a"}); err != nil { + if err := d.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } diff --git a/cmd/launch/hermes.go b/cmd/launch/hermes.go index 5ec723865..513662738 100644 --- a/cmd/launch/hermes.go +++ b/cmd/launch/hermes.go @@ -65,7 +65,7 @@ type Hermes struct{} func (h *Hermes) String() string { return "Hermes Agent" } -func (h *Hermes) Run(_ string, args []string) error { +func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error { // Hermes reads its primary model from config.yaml. launch configures that // default model ahead of time so we can keep runtime invocation simple and // still let Hermes discover additional models later via its own UX. diff --git a/cmd/launch/hermes_test.go b/cmd/launch/hermes_test.go index d88222ebf..fa2b17b4e 100644 --- a/cmd/launch/hermes_test.go +++ b/cmd/launch/hermes_test.go @@ -552,7 +552,7 @@ func TestHermesRunPassthroughArgs(t *testing.T) { } h := &Hermes{} - if err := h.Run("", []string{"--continue"}); err != nil { + if err := h.Run("", nil, []string{"--continue"}); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -603,7 +603,7 @@ fi } h := &Hermes{} - if err := h.Run("", nil); err != nil { + if err := h.Run("", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -655,10 +655,10 @@ func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) { } h := &Hermes{} - if err := h.Run("", nil); err != nil { + if err := h.Run("", nil, nil); err != nil { t.Fatalf("first Run returned error: %v", err) } - if err := h.Run("", nil); err != nil { + if err := h.Run("", nil, nil); err != nil { t.Fatalf("second Run returned error: %v", err) } @@ -713,7 +713,7 @@ func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) { } h := &Hermes{} - if err := h.Run("", nil); err != nil { + if err := h.Run("", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -753,7 +753,7 @@ func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) { } h := &Hermes{} - if err := h.Run("", nil); err != nil { + if err := h.Run("", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -798,7 +798,7 @@ fi } h := &Hermes{} - err := h.Run("", nil) + err := h.Run("", nil, nil) if err == nil { t.Fatal("expected messaging setup failure") } diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index f1ef59ee5..7b4347a06 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -25,7 +25,7 @@ type stubEditorRunner struct { editErr error } -func (s *stubEditorRunner) Run(model string, args []string) error { +func (s *stubEditorRunner) Run(model string, _ []LaunchModel, args []string) error { s.ranModel = model return nil } @@ -34,11 +34,11 @@ func (s *stubEditorRunner) String() string { return "StubEditor" } func (s *stubEditorRunner) Paths() []string { return nil } -func (s *stubEditorRunner) Edit(models []string) error { +func (s *stubEditorRunner) Edit(models []LaunchModel) error { if s.editErr != nil { return s.editErr } - cloned := append([]string(nil), models...) + cloned := launchModelNames(models) s.edited = append(s.edited, cloned) return nil } @@ -206,7 +206,7 @@ func TestAllIntegrations_HaveRequiredMethods(t *testing.T) { if displayName == "" { t.Error("String() should not return empty") } - var _ func(string, []string) error = r.Run + var _ func(string, []LaunchModel, []string) error = r.Run }) } } @@ -866,7 +866,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) { } editor := &stubEditorRunner{editErr: errors.New("boom")} - err := prepareEditorIntegration("droid", editor, []string{"new-model"}) + err := prepareEditorIntegration("droid", editor, testLaunchModels("new-model")) if err == nil || !strings.Contains(err.Error(), "setup failed") { t.Fatalf("expected setup failure, got %v", err) } diff --git a/cmd/launch/kimi.go b/cmd/launch/kimi.go index 04ca6c60d..6c4935f41 100644 --- a/cmd/launch/kimi.go +++ b/cmd/launch/kimi.go @@ -36,7 +36,7 @@ func (k *Kimi) args(config string, extra []string) []string { return args } -func (k *Kimi) Run(model string, args []string) error { +func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error { if strings.TrimSpace(model) == "" { return fmt.Errorf("model is required") } diff --git a/cmd/launch/kimi_test.go b/cmd/launch/kimi_test.go index 2c4c8a8e1..bb5783d2b 100644 --- a/cmd/launch/kimi_test.go +++ b/cmd/launch/kimi_test.go @@ -307,7 +307,7 @@ func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) { } t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) - err := k.Run("llama3.2", []string{"--model", "other"}) + err := k.Run("llama3.2", nil, []string{"--model", "other"}) if err == nil || !strings.Contains(err.Error(), "--model") { t.Fatalf("expected conflict error mentioning --model, got %v", err) } @@ -337,7 +337,7 @@ exit 0 t.Setenv("OLLAMA_HOST", srv.URL) k := &Kimi{} - if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil { + if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil { t.Fatalf("Run() error = %v", err) } diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 9311ca393..f121311f0 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -138,16 +138,17 @@ var isInteractiveSession = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) } -// Runner executes a model with an integration. +// Runner executes an integration with the selected model and its resolved +// launch metadata. models is ordered with the primary model first. type Runner interface { - Run(model string, args []string) error + Run(model string, models []LaunchModel, args []string) error String() string } // Editor can edit config files for integrations that support model configuration. type Editor interface { Paths() []string - Edit(models []string) error + Edit(models []LaunchModel) error Models() []string } @@ -166,7 +167,7 @@ type ManagedSingleModel interface { // ManagedModelListConfigurer lets managed single-model integrations receive // the launcher's model list while still preserving one primary selected model. type ManagedModelListConfigurer interface { - ConfigureWithModels(primary string, models []string) error + ConfigureWithModels(primary string, models []LaunchModel) error } // ManagedAutodiscoveryIntegration is for managed integrations that do not need @@ -240,18 +241,6 @@ type SupportedIntegration interface { Supported() error } -type modelInfo struct { - Name string - Remote bool - ToolCapable bool - Capabilities []modelpkg.Capability - Size int64 - Details api.ModelDetails -} - -// ModelInfo re-exports launcher model inventory details for callers. -type ModelInfo = modelInfo - // ModelItem represents model metadata before selector-only UI state is derived. type ModelItem struct { Name string @@ -416,8 +405,7 @@ func launchCommandIsClaudeDesktop(name string) bool { type launcherClient struct { apiClient *api.Client - modelInventory []ModelInfo - inventoryLoaded bool + inventory *modelInventory recommendationsLoaded bool recommendationItems []ModelItem accountState *AccountState @@ -434,10 +422,18 @@ func newLauncherClient(policy LaunchPolicy) (*launcherClient, error) { return &launcherClient{ apiClient: apiClient, + inventory: newModelInventory(apiClient), policy: policy, }, nil } +func (c *launcherClient) modelInventory() *modelInventory { + if c.inventory == nil { + c.inventory = newModelInventory(c.apiClient) + } + return c.inventory +} + // BuildLauncherState returns the launch-owned root launcher menu snapshot. func BuildLauncherState(ctx context.Context) (*LauncherState, error) { launchClient, err := newLauncherClient(defaultLaunchPolicy(isInteractiveSession(), false)) @@ -559,7 +555,7 @@ func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient } func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) { - _ = c.loadModelInventoryOnce(ctx) + _, _ = c.modelInventory().Load(ctx) state := &LauncherState{ LastSelection: config.LastSelection(), @@ -733,7 +729,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin } } - return launchAfterConfiguration(name, runner, target, req) + return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req) } func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { @@ -755,13 +751,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin return nil } + var launchModels []LaunchModel if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) { - if err := prepareEditorIntegration(name, editor, models); err != nil { + launchModels = c.modelInventory().Resolve(ctx, models) + if err := prepareEditorIntegration(name, editor, launchModels); err != nil { return err } + } else { + launchModels = c.resolveRunModels(ctx, models) } - return launchAfterConfiguration(name, runner, models[0], req) + return launchAfterConfiguration(name, runner, models[0], launchModels, req) } func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { @@ -790,7 +790,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam if err != nil { return err } - if err := prepareManagedSingleIntegration(name, managed, target, configureModels); err != nil { + if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil { return err } if refresher, ok := managed.(ManagedRuntimeRefresher); ok { @@ -820,7 +820,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return nil } - return runIntegration(runner, target, req.ExtraArgs) + return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs) } func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { @@ -863,7 +863,7 @@ func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Conte return nil } - return runIntegration(runner, target, req.ExtraArgs) + return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs) } func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool { @@ -1115,13 +1115,14 @@ func runMultiSelector(title string, items []SelectionItem, preChecked []string, } func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) { - if err := c.loadModelInventoryOnce(ctx); err != nil { + inventory, err := c.modelInventory().Load(ctx) + if err != nil { return nil, nil, err } recommendations := c.recommendations(ctx) cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient) - items, orderedChecked, _, _ := buildModelListWithRecommendations(c.modelInventory, recommendations, preChecked, current) + items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current) if cloudDisabled { items = filterCloudItems(items) orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked) @@ -1311,10 +1312,11 @@ func (c *launcherClient) filterDisabledCloudModels(ctx context.Context, models [ } func (c *launcherClient) savedModelUsable(ctx context.Context, name string) (bool, error) { - if err := c.loadModelInventoryOnce(ctx); err != nil { + inventory, err := c.modelInventory().Load(ctx) + if err != nil { return c.showBasedModelUsable(ctx, name) } - return c.singleModelUsable(ctx, name), nil + return c.singleModelUsable(ctx, name, inventory), nil } func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) (bool, error) { @@ -1340,7 +1342,7 @@ func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) return true, nil } -func (c *launcherClient) singleModelUsable(ctx context.Context, name string) bool { +func (c *launcherClient) singleModelUsable(ctx context.Context, name string, inventory []LaunchModel) bool { if name == "" { return false } @@ -1348,11 +1350,11 @@ func (c *launcherClient) singleModelUsable(ctx context.Context, name string) boo cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient) return !cloudDisabled } - return c.hasLocalModel(name) + return hasLocalModel(inventory, name) } -func (c *launcherClient) hasLocalModel(name string) bool { - for _, model := range c.modelInventory { +func hasLocalModel(inventory []LaunchModel, name string) bool { + for _, model := range inventory { if model.Remote { continue } @@ -1363,41 +1365,18 @@ func (c *launcherClient) hasLocalModel(name string) bool { return false } -func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error { - if c.inventoryLoaded { - return nil - } - - resp, err := c.apiClient.List(ctx) - if err != nil { - return err - } - - c.modelInventory = c.modelInventory[:0] - for _, model := range resp.Models { - c.modelInventory = append(c.modelInventory, ModelInfo{ - Name: model.Name, - Remote: model.RemoteModel != "", - ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools), - Capabilities: slices.Clone(model.Capabilities), - Size: model.Size, - Details: model.Details, - }) - } - - cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient) - if cloudDisabled { - c.modelInventory = filterCloudModels(c.modelInventory) - } - c.inventoryLoaded = true - return nil +func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel { + return c.modelInventory().Resolve(ctx, models) } -func runIntegration(runner Runner, modelName string, args []string) error { - return runner.Run(modelName, args) +func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error { + if len(models) == 0 && modelName != "" { + models = launchModelsFromNames([]string{modelName}) + } + return runner.Run(modelName, models, args) } -func launchAfterConfiguration(name string, runner Runner, model string, req IntegrationLaunchRequest) error { +func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error { if req.ConfigureOnly { launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner)) if err != nil { @@ -1410,7 +1389,7 @@ func launchAfterConfiguration(name string, runner Runner, model string, req Inte if err := EnsureIntegrationInstalled(name, runner); err != nil { return err } - return runIntegration(runner, model, req.ExtraArgs) + return runIntegration(runner, model, models, req.ExtraArgs) } func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) { diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index c813ce2c9..de59bf944 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -25,7 +25,7 @@ type launcherEditorRunner struct { ranModel string } -func (r *launcherEditorRunner) Run(model string, args []string) error { +func (r *launcherEditorRunner) Run(model string, _ []LaunchModel, args []string) error { r.ranModel = model return nil } @@ -34,8 +34,8 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" } func (r *launcherEditorRunner) Paths() []string { return r.paths } -func (r *launcherEditorRunner) Edit(models []string) error { - r.edited = append(r.edited, append([]string(nil), models...)) +func (r *launcherEditorRunner) Edit(models []LaunchModel) error { + r.edited = append(r.edited, launchModelNames(models)) return nil } @@ -45,7 +45,7 @@ type launcherSingleRunner struct { ranModel string } -func (r *launcherSingleRunner) Run(model string, args []string) error { +func (r *launcherSingleRunner) Run(model string, _ []LaunchModel, args []string) error { r.ranModel = model return nil } @@ -83,7 +83,7 @@ type launcherManagedRunner struct { skipModelReadiness bool } -func (r *launcherManagedRunner) Run(model string, args []string) error { +func (r *launcherManagedRunner) Run(model string, _ []LaunchModel, args []string) error { r.ranModel = model return nil } @@ -133,8 +133,8 @@ type launcherManagedListRunner struct { configuredModelLists [][]string } -func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error { - r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...)) +func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error { + r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models)) return r.Configure(primary) } diff --git a/cmd/launch/model_inventory.go b/cmd/launch/model_inventory.go new file mode 100644 index 000000000..707ebe02a --- /dev/null +++ b/cmd/launch/model_inventory.go @@ -0,0 +1,201 @@ +package launch + +import ( + "context" + "slices" + "strings" + "sync" + + "github.com/ollama/ollama/api" + modelpkg "github.com/ollama/ollama/types/model" +) + +// LaunchModel is the model metadata Launch passes to integration config +// writers after resolving selected model names through the per-run inventory. +type LaunchModel struct { + Name string + Remote bool + ToolCapable bool + Capabilities []modelpkg.Capability + ContextLength int + MaxOutputTokens int + EmbeddingLength int + Size int64 + Details api.ModelDetails +} + +type modelInfo = LaunchModel + +// ModelInfo re-exports launcher model inventory details for callers. +type ModelInfo = LaunchModel + +func (m LaunchModel) HasCapability(capability modelpkg.Capability) bool { + return slices.Contains(m.Capabilities, capability) +} + +func (m LaunchModel) WithCloudLimits() LaunchModel { + if limit, ok := lookupCloudModelLimit(m.Name); ok { + if m.ContextLength <= 0 { + m.ContextLength = limit.Context + } + if m.MaxOutputTokens <= 0 { + m.MaxOutputTokens = limit.Output + } + } + return m +} + +type modelInventory struct { + client *api.Client + + mu sync.Mutex + loaded bool + models []LaunchModel + err error +} + +func newModelInventory(client *api.Client) *modelInventory { + return &modelInventory{client: client} +} + +func (i *modelInventory) Load(ctx context.Context) ([]LaunchModel, error) { + return i.load(ctx, false) +} + +func (i *modelInventory) Refresh(ctx context.Context) ([]LaunchModel, error) { + return i.load(ctx, true) +} + +func (i *modelInventory) load(ctx context.Context, force bool) ([]LaunchModel, error) { + if i == nil || i.client == nil { + return nil, nil + } + + i.mu.Lock() + defer i.mu.Unlock() + + if i.loaded && !force { + return cloneLaunchModels(i.models), i.err + } + + resp, err := i.client.List(ctx) + if err != nil { + i.models = nil + i.err = err + i.loaded = true + return nil, err + } + + i.models = make([]LaunchModel, 0, len(resp.Models)) + for _, model := range resp.Models { + i.models = append(i.models, launchModelFromListResponse(model)) + } + i.err = nil + i.loaded = true + + return cloneLaunchModels(i.models), i.err +} + +func (i *modelInventory) Resolve(ctx context.Context, names []string) []LaunchModel { + names = dedupeModelList(names) + if len(names) == 0 { + return nil + } + + models, err := i.Load(ctx) + if err != nil { + models = nil + } + + resolved, localMiss := resolveLaunchModels(names, models) + if localMiss { + if refreshed, err := i.Refresh(ctx); err == nil { + resolved, _ = resolveLaunchModels(names, refreshed) + } + } + return resolved +} + +func resolveLaunchModels(names []string, models []LaunchModel) ([]LaunchModel, bool) { + resolved := make([]LaunchModel, 0, len(names)) + localMiss := false + for _, name := range names { + if model, ok := findLaunchModel(models, name); ok { + resolved = append(resolved, model.WithCloudLimits()) + continue + } + if !isCloudModelName(name) { + localMiss = true + } + resolved = append(resolved, fallbackLaunchModel(name)) + } + return resolved, localMiss +} + +func launchModelFromListResponse(model api.ListModelResponse) LaunchModel { + return LaunchModel{ + Name: model.Name, + Remote: model.RemoteModel != "", + ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools), + Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...), + ContextLength: model.Details.ContextLength, + EmbeddingLength: model.Details.EmbeddingLength, + Size: model.Size, + Details: model.Details, + }.WithCloudLimits() +} + +func fallbackLaunchModel(name string) LaunchModel { + return LaunchModel{Name: name, Remote: isCloudModelName(name)}.WithCloudLimits() +} + +func findLaunchModel(models []LaunchModel, name string) (LaunchModel, bool) { + for _, model := range models { + if launchModelMatches(model.Name, name) { + return cloneLaunchModel(model), true + } + } + return LaunchModel{}, false +} + +func launchModelMatches(candidate, name string) bool { + if candidate == name { + return true + } + return strings.TrimSuffix(candidate, ":latest") == name +} + +func cloneLaunchModel(model LaunchModel) LaunchModel { + model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...) + model.Details.Families = append([]string(nil), model.Details.Families...) + return model +} + +func cloneLaunchModels(models []LaunchModel) []LaunchModel { + cloned := make([]LaunchModel, len(models)) + for i, model := range models { + cloned[i] = cloneLaunchModel(model) + } + return cloned +} + +func launchModelNames(models []LaunchModel) []string { + names := make([]string, 0, len(models)) + for _, model := range models { + if model.Name != "" { + names = append(names, model.Name) + } + } + return names +} + +func launchModelsFromNames(names []string) []LaunchModel { + models := make([]LaunchModel, 0, len(names)) + for _, name := range names { + if name == "" { + continue + } + models = append(models, fallbackLaunchModel(name)) + } + return models +} diff --git a/cmd/launch/model_inventory_test.go b/cmd/launch/model_inventory_test.go new file mode 100644 index 000000000..2a8b8075e --- /dev/null +++ b/cmd/launch/model_inventory_test.go @@ -0,0 +1,80 @@ +package launch + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ollama/ollama/api" + modelpkg "github.com/ollama/ollama/types/model" +) + +func TestModelInventoryResolveRefreshesLocalMiss(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + calls++ + if calls == 1 { + fmt.Fprint(w, `{"models":[]}`) + return + } + fmt.Fprint(w, `{"models":[{"name":"new-model","size":123,"details":{"context_length":65536,"embedding_length":1024},"capabilities":["vision","tools"]}]}`) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + inventory := newModelInventory(api.NewClient(u, srv.Client())) + + got := inventory.Resolve(context.Background(), []string{"new-model"}) + if calls != 2 { + t.Fatalf("List calls = %d, want 2", calls) + } + if len(got) != 1 { + t.Fatalf("Resolve returned %d models, want 1", len(got)) + } + if got[0].Name != "new-model" { + t.Fatalf("Name = %q, want new-model", got[0].Name) + } + if got[0].ContextLength != 65_536 || got[0].EmbeddingLength != 1_024 { + t.Fatalf("metadata = context %d embedding %d, want refreshed metadata", got[0].ContextLength, got[0].EmbeddingLength) + } + if !got[0].HasCapability(modelpkg.CapabilityVision) || !got[0].ToolCapable { + t.Fatalf("capabilities = %v toolCapable=%v, want refreshed capabilities", got[0].Capabilities, got[0].ToolCapable) + } +} + +func TestModelInventoryResolveDoesNotRefreshCloudMiss(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + calls++ + fmt.Fprint(w, `{"models":[]}`) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + inventory := newModelInventory(api.NewClient(u, srv.Client())) + + got := inventory.Resolve(context.Background(), []string{"glm-5.1:cloud"}) + if calls != 1 { + t.Fatalf("List calls = %d, want 1", calls) + } + if len(got) != 1 { + t.Fatalf("Resolve returned %d models, want 1", len(got)) + } + if got[0].Name != "glm-5.1:cloud" || !got[0].Remote { + t.Fatalf("resolved model = %#v, want cloud fallback", got[0]) + } + if got[0].ContextLength <= 0 || got[0].MaxOutputTokens <= 0 { + t.Fatalf("cloud limits not applied: %#v", got[0]) + } +} diff --git a/cmd/launch/models.go b/cmd/launch/models.go index 2114dfb8e..97ef79376 100644 --- a/cmd/launch/models.go +++ b/cmd/launch/models.go @@ -299,18 +299,17 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err } // prepareEditorIntegration persists models and applies editor-managed config files. -func prepareEditorIntegration(name string, editor Editor, models []string) error { +func prepareEditorIntegration(name string, editor Editor, models []LaunchModel) error { if err := editor.Edit(models); err != nil { return fmt.Errorf("setup failed: %w", err) } - if err := config.SaveIntegration(name, models); err != nil { + if err := config.SaveIntegration(name, launchModelNames(models)); err != nil { return fmt.Errorf("failed to save: %w", err) } return nil } -func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []string) error { - models = dedupeModelList(append([]string{model}, models...)) +func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []LaunchModel) error { var err error if withModels, ok := managed.(ManagedModelListConfigurer); ok { err = withModels.ConfigureWithModels(model, models) diff --git a/cmd/launch/openclaw.go b/cmd/launch/openclaw.go index b7ffcc77d..5b70cb47a 100644 --- a/cmd/launch/openclaw.go +++ b/cmd/launch/openclaw.go @@ -1,7 +1,6 @@ package launch import ( - "context" "encoding/json" "fmt" "net" @@ -10,21 +9,15 @@ import ( "os/exec" "path/filepath" "runtime" - "slices" "strings" "time" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" - "github.com/ollama/ollama/types/model" ) const defaultGatewayPort = 18789 -// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls. -var openclawModelShowTimeout = 5 * time.Second - // openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install var openclawFreshInstall bool @@ -34,7 +27,7 @@ type Openclaw struct{} func (c *Openclaw) String() string { return "OpenClaw" } -func (c *Openclaw) Run(model string, args []string) error { +func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error { bin, err := ensureOpenclawInstalled() if err != nil { return err @@ -656,7 +649,7 @@ func (c *Openclaw) Paths() []string { return nil } -func (c *Openclaw) Edit(models []string) error { +func (c *Openclaw) Edit(models []LaunchModel) error { if len(models) == 0 { return nil } @@ -710,13 +703,11 @@ func (c *Openclaw) Edit(models []string) error { } } - client, _ := api.ClientFromEnvironment() - var newModels []any for _, m := range models { - entry, _ := openclawModelConfig(context.Background(), client, m) + entry, _ := openclawModelConfig(m) // Merge existing fields (user customizations) - if existing, ok := existingByID[m]; ok { + if existing, ok := existingByID[m.Name]; ok { for k, v := range existing { if _, isNew := entry[k]; !isNew { entry[k] = v @@ -744,7 +735,7 @@ func (c *Openclaw) Edit(models []string) error { if modelConfig == nil { modelConfig = make(map[string]any) } - modelConfig["primary"] = "ollama/" + models[0] + modelConfig["primary"] = "ollama/" + models[0].Name defaults["model"] = modelConfig agents["defaults"] = defaults config["agents"] = agents @@ -759,7 +750,7 @@ func (c *Openclaw) Edit(models []string) error { // Clear any per-session model overrides so the new primary takes effect // immediately rather than being shadowed by a cached modelOverride. - clearSessionModelOverride(models[0]) + clearSessionModelOverride(models[0].Name) return nil } @@ -936,10 +927,10 @@ func configureOllamaWebSearch() { // openclawModelConfig builds an OpenClaw model config entry with capability detection. // The second return value indicates whether the model is a cloud (remote) model. -func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) { +func openclawModelConfig(model LaunchModel) (map[string]any, bool) { entry := map[string]any{ - "id": modelID, - "name": modelID, + "id": model.Name, + "name": model.Name, "input": []any{"text"}, "cost": map[string]any{ "input": 0, @@ -949,53 +940,24 @@ func openclawModelConfig(ctx context.Context, client *api.Client, modelID string }, } - if client == nil { - return entry, false - } - - showCtx := ctx - if _, hasDeadline := ctx.Deadline(); !hasDeadline { - var cancel context.CancelFunc - showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout) - defer cancel() - } - - resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID}) - if err != nil { - return entry, false - } - // Set input types based on vision capability - if slices.Contains(resp.Capabilities, model.CapabilityVision) { + if model.HasCapability("vision") { entry["input"] = []any{"text", "image"} } // Set reasoning based on thinking capability - if slices.Contains(resp.Capabilities, model.CapabilityThinking) { + if model.HasCapability("thinking") { entry["reasoning"] = true } - // Cloud models: use hardcoded limits for context/output tokens. - // Capability detection above still applies (vision, thinking). - if resp.RemoteModel != "" { - if l, ok := lookupCloudModelLimit(modelID); ok { - entry["contextWindow"] = l.Context - entry["maxTokens"] = l.Output - } - return entry, true + if model.ContextLength > 0 { + entry["contextWindow"] = model.ContextLength + } + if model.MaxOutputTokens > 0 { + entry["maxTokens"] = model.MaxOutputTokens } - // Extract context window from ModelInfo (local models only) - for key, val := range resp.ModelInfo { - if strings.HasSuffix(key, ".context_length") { - if ctxLen, ok := val.(float64); ok && ctxLen > 0 { - entry["contextWindow"] = int(ctxLen) - } - break - } - } - - return entry, false + return entry, model.Remote || isCloudModelName(model.Name) } func (c *Openclaw) Models() []string { diff --git a/cmd/launch/openclaw_test.go b/cmd/launch/openclaw_test.go index ce98bc4c0..f0ce517b3 100644 --- a/cmd/launch/openclaw_test.go +++ b/cmd/launch/openclaw_test.go @@ -2,12 +2,9 @@ package launch import ( "bytes" - "context" "encoding/json" "fmt" "net" - "net/http" - "net/http/httptest" "net/url" "os" "path/filepath" @@ -16,8 +13,8 @@ import ( "testing" "time" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/types/model" ) func TestOpenclawIntegration(t *testing.T) { @@ -78,7 +75,7 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) { defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() c := &Openclaw{} - if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil { + if err := c.Run("llama3.2", nil, []string{"gateway", "--someflag"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -152,7 +149,7 @@ fi defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() c := &Openclaw{} - if err := c.Run("llama3.2", nil); err != nil { + if err := c.Run("llama3.2", nil, nil); err != nil { t.Fatalf("Run() error = %v", err) } @@ -224,7 +221,7 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) { defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() c := &Openclaw{} - if err := c.Run("llama3.2", nil); err != nil { + if err := c.Run("llama3.2", nil, nil); err != nil { t.Fatalf("Run() error = %v", err) } @@ -287,7 +284,7 @@ exit 0 defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() c := &Openclaw{} - if err := c.Run("llama3.2", []string{"status"}); err != nil { + if err := c.Run("llama3.2", nil, []string{"status"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -367,7 +364,7 @@ exit 0 defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() c := &Openclaw{} - if err := c.Run("llama3.2", []string{"tui"}); err != nil { + if err := c.Run("llama3.2", nil, []string{"tui"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -617,7 +614,7 @@ func TestOpenclawEdit(t *testing.T) { t.Run("fresh install", func(t *testing.T) { cleanup() - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "llama3.2") @@ -626,7 +623,7 @@ func TestOpenclawEdit(t *testing.T) { t.Run("multiple models - first is primary", func(t *testing.T) { cleanup() - if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "llama3.2") @@ -638,7 +635,7 @@ func TestOpenclawEdit(t *testing.T) { cleanup() os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } data, _ := os.ReadFile(configPath) @@ -655,7 +652,7 @@ func TestOpenclawEdit(t *testing.T) { cleanup() os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } data, _ := os.ReadFile(configPath) @@ -671,7 +668,7 @@ func TestOpenclawEdit(t *testing.T) { t.Run("preserve user customizations on models", func(t *testing.T) { cleanup() - c.Edit([]string{"llama3.2"}) + c.Edit(testLaunchModels("llama3.2")) // User adds custom field data, _ := os.ReadFile(configPath) @@ -687,7 +684,7 @@ func TestOpenclawEdit(t *testing.T) { os.WriteFile(configPath, configData, 0o644) // Re-run Edit - c.Edit([]string{"llama3.2"}) + c.Edit(testLaunchModels("llama3.2")) data, _ = os.ReadFile(configPath) json.Unmarshal(data, &cfg) @@ -703,8 +700,8 @@ func TestOpenclawEdit(t *testing.T) { t.Run("edit replaces models list", func(t *testing.T) { cleanup() - c.Edit([]string{"llama3.2", "mistral"}) - c.Edit([]string{"llama3.2"}) + c.Edit(testLaunchModels("llama3.2", "mistral")) + c.Edit(testLaunchModels("llama3.2")) assertOpenclawModelExists(t, configPath, "llama3.2") assertOpenclawModelNotExists(t, configPath, "mistral") @@ -716,7 +713,7 @@ func TestOpenclawEdit(t *testing.T) { original := `{"existing":"data"}` os.WriteFile(configPath, []byte(original), 0o644) - c.Edit([]string{}) + c.Edit(testLaunchModels()) data, _ := os.ReadFile(configPath) if string(data) != original { @@ -729,7 +726,7 @@ func TestOpenclawEdit(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{corrupted`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -745,7 +742,7 @@ func TestOpenclawEdit(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "llama3.2") @@ -925,7 +922,7 @@ func TestOpenclawEditSchemaFields(t *testing.T) { setTestHome(t, tmpDir) configPath := filepath.Join(tmpDir, ".openclaw", "openclaw.json") - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -966,7 +963,7 @@ func TestOpenclawEditModelNames(t *testing.T) { t.Run("model with colon tag", func(t *testing.T) { cleanup() - if err := c.Edit([]string{"llama3.2:70b"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2:70b")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "llama3.2:70b") @@ -975,7 +972,7 @@ func TestOpenclawEditModelNames(t *testing.T) { t.Run("model with slash", func(t *testing.T) { cleanup() - if err := c.Edit([]string{"library/model:tag"}); err != nil { + if err := c.Edit(testLaunchModels("library/model:tag")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "library/model:tag") @@ -984,7 +981,7 @@ func TestOpenclawEditModelNames(t *testing.T) { t.Run("model with hyphen", func(t *testing.T) { cleanup() - if err := c.Edit([]string{"test-model"}); err != nil { + if err := c.Edit(testLaunchModels("test-model")); err != nil { t.Fatal(err) } assertOpenclawModelExists(t, configPath, "test-model") @@ -1004,7 +1001,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644) - c.Edit([]string{"llama3.2"}) + c.Edit(testLaunchModels("llama3.2")) data, _ := os.ReadFile(configPath) var cfg map[string]any @@ -1021,7 +1018,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644) - c.Edit([]string{"llama3.2"}) + c.Edit(testLaunchModels("llama3.2")) data, _ := os.ReadFile(configPath) var cfg map[string]any @@ -1061,7 +1058,7 @@ func TestOpenclawEdit_RoundTrip(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644) - if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil { t.Fatal(err) } @@ -1107,10 +1104,10 @@ func TestOpenclawEdit_Idempotent(t *testing.T) { os.MkdirAll(configDir, 0o755) os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644) - c.Edit([]string{"llama3.2", "mistral"}) + c.Edit(testLaunchModels("llama3.2", "mistral")) firstData, _ := os.ReadFile(configPath) - c.Edit([]string{"llama3.2", "mistral"}) + c.Edit(testLaunchModels("llama3.2", "mistral")) secondData, _ := os.ReadFile(configPath) if string(firstData) != string(secondData) { @@ -1133,7 +1130,7 @@ func TestOpenclawEdit_MultipleConsecutiveEdits(t *testing.T) { if i%2 == 0 { models = []string{"model-x", "model-y", "model-z"} } - if err := c.Edit(models); err != nil { + if err := c.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("edit %d failed: %v", i, err) } } @@ -1162,7 +1159,7 @@ func TestOpenclawEdit_BackupCreated(t *testing.T) { original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker) os.WriteFile(configPath, []byte(original), 0o644) - if err := c.Edit([]string{"model-a"}); err != nil { + if err := c.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -1284,7 +1281,7 @@ func TestOpenclawLegacyPaths(t *testing.T) { os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"theme":"new"}`), 0o644) os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"legacy"}`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -1303,7 +1300,7 @@ func TestOpenclawLegacyPaths(t *testing.T) { os.MkdirAll(legacyDir, 0o755) os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"dark"}`), 0o644) - if err := c.Edit([]string{"llama3.2"}); err != nil { + if err := c.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -1331,7 +1328,7 @@ func TestOpenclawEdit_CreatesDirectoryIfMissing(t *testing.T) { t.Fatal("directory should not exist before test") } - if err := c.Edit([]string{"model-a"}); err != nil { + if err := c.Edit(testLaunchModels("model-a")); err != nil { t.Fatal(err) } @@ -2248,8 +2245,8 @@ func TestPrintOpenclawReady(t *testing.T) { } func TestOpenclawModelConfig(t *testing.T) { - t.Run("nil client returns base config", func(t *testing.T) { - cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2") + t.Run("minimal model returns base config", func(t *testing.T) { + cfg, _ := openclawModelConfig(fallbackLaunchModel("llama3.2")) if cfg["id"] != "llama3.2" { t.Errorf("id = %v, want llama3.2", cfg["id"]) @@ -2260,29 +2257,17 @@ func TestOpenclawModelConfig(t *testing.T) { if cfg["cost"] == nil { t.Error("cost should be set") } - // Should not have capability fields without API + // Should not have capability fields without inventory metadata. if _, ok := cfg["reasoning"]; ok { - t.Error("reasoning should not be set without API") + t.Error("reasoning should not be set without metadata") } if _, ok := cfg["contextWindow"]; ok { - t.Error("contextWindow should not be set without API") + t.Error("contextWindow should not be set without metadata") } }) t.Run("sets vision input when model has vision capability", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b") + cfg, _ := openclawModelConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{"vision"}, ContextLength: 4096}) input, ok := cfg["input"].([]any) if !ok || len(input) != 2 { @@ -2291,19 +2276,7 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("sets text-only input when model lacks vision", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2") + cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{"completion"}}) input, ok := cfg["input"].([]any) if !ok || len(input) != 1 { @@ -2315,39 +2288,15 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("sets reasoning when model has thinking capability", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "qwq") + cfg, _ := openclawModelConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{"thinking"}}) if cfg["reasoning"] != true { t.Error("expected reasoning = true for thinking model") } }) - t.Run("extracts context window from model info", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2") + t.Run("sets context window from inventory metadata", func(t *testing.T) { + cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072}) if cfg["contextWindow"] != 131072 { t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"]) @@ -2355,19 +2304,11 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("handles all capabilities together", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision") + cfg, _ := openclawModelConfig(LaunchModel{ + Name: "qwen3-vision", + Capabilities: []model.Capability{"vision", "thinking"}, + ContextLength: 32768, + }) input, ok := cfg["input"].([]any) if !ok || len(input) != 2 { @@ -2381,17 +2322,8 @@ func TestOpenclawModelConfig(t *testing.T) { } }) - t.Run("returns base config when show fails", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, `{"error":"model not found"}`) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "missing-model") + t.Run("returns base config when metadata is unavailable", func(t *testing.T) { + cfg, _ := openclawModelConfig(fallbackLaunchModel("missing-model")) if cfg["id"] != "missing-model" { t.Errorf("id = %v, want missing-model", cfg["id"]) @@ -2401,62 +2333,15 @@ func TestOpenclawModelConfig(t *testing.T) { t.Error("input should always be set") } if _, ok := cfg["reasoning"]; ok { - t.Error("reasoning should not be set when show fails") + t.Error("reasoning should not be set when metadata is unavailable") } if _, ok := cfg["contextWindow"]; ok { - t.Error("contextWindow should not be set when show fails") - } - }) - - t.Run("times out slow show and returns base config", func(t *testing.T) { - oldTimeout := openclawModelShowTimeout - openclawModelShowTimeout = 50 * time.Millisecond - t.Cleanup(func() { openclawModelShowTimeout = oldTimeout }) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - time.Sleep(300 * time.Millisecond) - fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - start := time.Now() - cfg, _ := openclawModelConfig(context.Background(), client, "slow-model") - elapsed := time.Since(start) - if elapsed >= 250*time.Millisecond { - t.Fatalf("openclawModelConfig took too long: %v", elapsed) - } - if cfg["id"] != "slow-model" { - t.Errorf("id = %v, want slow-model", cfg["id"]) - } - if _, ok := cfg["reasoning"]; ok { - t.Error("reasoning should not be set on timeout") - } - if _, ok := cfg["contextWindow"]; ok { - t.Error("contextWindow should not be set on timeout") + t.Error("contextWindow should not be set when metadata is unavailable") } }) t.Run("skips zero context length", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, _ := openclawModelConfig(context.Background(), client, "test-model") + cfg, _ := openclawModelConfig(LaunchModel{Name: "test-model", ContextLength: 0}) if _, ok := cfg["contextWindow"]; ok { t.Error("contextWindow should not be set for zero value") @@ -2464,21 +2349,7 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("cloud model uses hardcoded limits", func(t *testing.T) { - // Use a model name that's in cloudModelLimits and make the server - // report it as a remote/cloud model - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.7"}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.7:cloud") + cfg, isCloud := openclawModelConfig(fallbackLaunchModel("minimax-m2.7:cloud")) if !isCloud { t.Error("expected isCloud = true for cloud model") @@ -2492,21 +2363,11 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("cloud model with vision capability gets image input", func(t *testing.T) { - // Regression test: cloud models must not skip capability detection. - // A cloud model that reports vision capability should have input: [text, image]. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud") + cfg, isCloud := openclawModelConfig(LaunchModel{ + Name: "qwen3-vl:235b-cloud", + Remote: true, + Capabilities: []model.Capability{"vision"}, + }.WithCloudLimits()) if !isCloud { t.Error("expected isCloud = true for cloud vision model") @@ -2518,21 +2379,11 @@ func TestOpenclawModelConfig(t *testing.T) { }) t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) { - // Regression test: cloud models must not skip capability detection. - // A cloud model that reports thinking capability should have reasoning: true. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud") + cfg, isCloud := openclawModelConfig(LaunchModel{ + Name: "qwq:cloud", + Remote: true, + Capabilities: []model.Capability{"thinking"}, + }) if !isCloud { t.Error("expected isCloud = true for cloud thinking model") diff --git a/cmd/launch/opencode.go b/cmd/launch/opencode.go index 3fcaec902..98dbf440f 100644 --- a/cmd/launch/opencode.go +++ b/cmd/launch/opencode.go @@ -1,7 +1,6 @@ package launch import ( - "context" "encoding/json" "fmt" "os" @@ -9,12 +8,9 @@ import ( "path/filepath" "runtime" "slices" - "time" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" - "github.com/ollama/ollama/types/model" ) // OpenCode implements Runner and Editor for OpenCode integration. @@ -24,8 +20,6 @@ type OpenCode struct { configContent string // JSON config built by Edit, passed to Run via env var } -const openCodeModelShowTimeout = 2 * time.Second - func (o *OpenCode) String() string { return "OpenCode" } // findOpenCode returns the opencode binary path, checking PATH first then the @@ -49,7 +43,7 @@ func findOpenCode() (string, bool) { return "", false } -func (o *OpenCode) Run(model string, args []string) error { +func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error { opencodePath, ok := findOpenCode() if !ok { return fmt.Errorf("opencode is not installed, install from https://opencode.ai") @@ -60,7 +54,7 @@ func (o *OpenCode) Run(model string, args []string) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = os.Environ() - if content := o.resolveContent(model); content != "" { + if content := o.resolveContent(model, models); content != "" { cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content) } return cmd.Run() @@ -69,21 +63,57 @@ func (o *OpenCode) Run(model string, args []string) error { // resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT. // Returns content built by Edit if available, otherwise builds from model.json // with the requested model as primary (e.g. re-launch with saved config). -func (o *OpenCode) resolveContent(model string) string { +func (o *OpenCode) resolveContent(model string, models []LaunchModel) string { if o.configContent != "" { return o.configContent } - models := readModelJSONModels() - if !slices.Contains(models, model) { - models = append([]string{model}, models...) + resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels()) + if len(resolvedModels) == 0 { + return "" } - content, err := buildInlineConfig(model, models) + content, err := buildInlineConfig(resolvedModels[0], resolvedModels) if err != nil { return "" } return content } +func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel { + if primary == "" { + return nil + } + + resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels)) + appendModel := func(name string) { + if name == "" || hasLaunchModel(resolved, name) { + return + } + if model, ok := findLaunchModel(models, name); ok { + resolved = append(resolved, model) + return + } + resolved = append(resolved, fallbackLaunchModel(name)) + } + + appendModel(primary) + for _, model := range models { + appendModel(model.Name) + } + for _, model := range stateModels { + appendModel(model) + } + return resolved +} + +func hasLaunchModel(models []LaunchModel, name string) bool { + for _, model := range models { + if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) { + return true + } + } + return false +} + func (o *OpenCode) Paths() []string { sp, err := openCodeStatePath() if err != nil { @@ -106,12 +136,13 @@ func openCodeStatePath() (string, error) { return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil } -func (o *OpenCode) Edit(modelList []string) error { +func (o *OpenCode) Edit(models []LaunchModel) error { + modelList := launchModelNames(models) if len(modelList) == 0 { return nil } - content, err := buildInlineConfig(modelList[0], modelList) + content, err := buildInlineConfig(models[0], models) if err != nil { return err } @@ -178,16 +209,11 @@ func (o *OpenCode) Models() []string { // buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT. // primary is the model to launch with, models is the full list of available models. -func buildInlineConfig(primary string, models []string) (string, error) { - if primary == "" || len(models) == 0 { +func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) { + if primary.Name == "" || len(models) == 0 { return "", fmt.Errorf("buildInlineConfig: primary and models are required") } - client, err := api.ClientFromEnvironment() - if err != nil { - client = nil - } - config := map[string]any{ "$schema": "https://opencode.ai/config.json", "provider": map[string]any{ @@ -197,10 +223,10 @@ func buildInlineConfig(primary string, models []string) (string, error) { "options": map[string]any{ "baseURL": envconfig.Host().String() + "/v1", }, - "models": buildModelEntries(context.Background(), client, models), + "models": buildModelEntries(models), }, }, - "model": "ollama/" + primary, + "model": "ollama/" + primary.Name, } data, err := json.Marshal(config) if err != nil { @@ -240,39 +266,29 @@ func readModelJSONModels() []string { return models } -func buildModelEntries(ctx context.Context, client *api.Client, modelList []string) map[string]any { - if client != nil { - var cancel context.CancelFunc - if _, hasDeadline := ctx.Deadline(); !hasDeadline { - ctx, cancel = context.WithTimeout(ctx, openCodeModelShowTimeout) - defer cancel() - } - } - +func buildModelEntries(modelList []LaunchModel) map[string]any { models := make(map[string]any) - for _, modelID := range modelList { + for _, model := range modelList { entry := map[string]any{ - "name": modelID, + "name": model.Name, } - if client != nil { - if resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID}); err == nil { - if slices.Contains(resp.Capabilities, model.CapabilityVision) { - entry["modalities"] = map[string]any{ - "input": []string{"text", "image"}, - "output": []string{"text"}, - } - } + if model.HasCapability("vision") { + entry["modalities"] = map[string]any{ + "input": []string{"text", "image"}, + "output": []string{"text"}, } } - if isCloudModelName(modelID) { - if l, ok := lookupCloudModelLimit(modelID); ok { - entry["limit"] = map[string]any{ - "context": l.Context, - "output": l.Output, - } + if model.ContextLength > 0 || model.MaxOutputTokens > 0 { + limit := make(map[string]any) + if model.ContextLength > 0 { + limit["context"] = model.ContextLength } + if model.MaxOutputTokens > 0 { + limit["output"] = model.MaxOutputTokens + } + entry["limit"] = limit } - models[modelID] = entry + models[model.Name] = entry } return models } diff --git a/cmd/launch/opencode_test.go b/cmd/launch/opencode_test.go index 54d721e85..e43f78ab1 100644 --- a/cmd/launch/opencode_test.go +++ b/cmd/launch/opencode_test.go @@ -1,21 +1,14 @@ package launch import ( - "context" "encoding/json" "fmt" - "io" - "net/http" - "net/url" "os" "path/filepath" "runtime" - "strings" - "sync" "testing" - "time" - "github.com/ollama/ollama/api" + "github.com/ollama/ollama/types/model" ) func TestOpenCodeIntegration(t *testing.T) { @@ -40,7 +33,7 @@ func TestOpenCodeEdit(t *testing.T) { t.Run("builds config content with provider", func(t *testing.T) { setTestHome(t, t.TempDir()) o := &OpenCode{} - if err := o.Edit([]string{"llama3.2"}); err != nil { + if err := o.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -74,7 +67,7 @@ func TestOpenCodeEdit(t *testing.T) { t.Run("multiple models", func(t *testing.T) { setTestHome(t, t.TempDir()) o := &OpenCode{} - if err := o.Edit([]string{"llama3.2", "qwen3:32b"}); err != nil { + if err := o.Edit(testLaunchModels("llama3.2", "qwen3:32b")); err != nil { t.Fatal(err) } @@ -99,7 +92,7 @@ func TestOpenCodeEdit(t *testing.T) { t.Run("empty models is no-op", func(t *testing.T) { setTestHome(t, t.TempDir()) o := &OpenCode{} - if err := o.Edit([]string{}); err != nil { + if err := o.Edit(testLaunchModels()); err != nil { t.Fatal(err) } if o.configContent != "" { @@ -111,7 +104,7 @@ func TestOpenCodeEdit(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) o := &OpenCode{} - o.Edit([]string{"llama3.2"}) + o.Edit(testLaunchModels("llama3.2")) configDir := filepath.Join(tmpDir, ".config", "opencode") @@ -126,7 +119,7 @@ func TestOpenCodeEdit(t *testing.T) { t.Run("cloud model has limits", func(t *testing.T) { setTestHome(t, t.TempDir()) o := &OpenCode{} - if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil { + if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil { t.Fatal(err) } @@ -153,7 +146,7 @@ func TestOpenCodeEdit(t *testing.T) { t.Run("local model has no limits", func(t *testing.T) { setTestHome(t, t.TempDir()) o := &OpenCode{} - o.Edit([]string{"llama3.2"}) + o.Edit(testLaunchModels("llama3.2")) var cfg map[string]any json.Unmarshal([]byte(o.configContent), &cfg) @@ -168,37 +161,7 @@ func TestOpenCodeEdit(t *testing.T) { }) t.Run("vision model gets image input modalities", func(t *testing.T) { - u, err := url.Parse("http://ollama.example") - if err != nil { - t.Fatalf("parse test URL: %v", err) - } - client := api.NewClient(u, &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.Path != "/api/show" { - return &http.Response{ - StatusCode: http.StatusNotFound, - Body: io.NopCloser(strings.NewReader("not found")), - Header: make(http.Header), - }, nil - } - - var body struct { - Model string `json:"model"` - } - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { - t.Fatalf("decode show request: %v", err) - } - if body.Model != "gemma4:26b" { - t.Fatalf("show request model = %q, want gemma4:26b", body.Model) - } - - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"capabilities":["vision"],"model_info":{}}`)), - Header: make(http.Header), - }, nil - })}) - - models := buildModelEntries(context.Background(), client, []string{"gemma4:26b"}) + models := buildModelEntries([]LaunchModel{{Name: "gemma4:26b", Capabilities: []model.Capability{"vision"}}}) entry, _ := models["gemma4:26b"].(map[string]any) modalities, _ := entry["modalities"].(map[string]any) input, _ := modalities["input"].([]string) @@ -214,55 +177,23 @@ func TestOpenCodeEdit(t *testing.T) { } func TestBuildModelEntries(t *testing.T) { - t.Run("defaults to model name when capabilities cannot be probed", func(t *testing.T) { - models := buildModelEntries(context.Background(), nil, []string{"llama3.2"}) + t.Run("defaults to model name without capabilities", func(t *testing.T) { + models := buildModelEntries(testLaunchModels("llama3.2")) entry, _ := models["llama3.2"].(map[string]any) if entry["name"] != "llama3.2" { t.Fatalf("name = %v, want llama3.2", entry["name"]) } if _, ok := entry["modalities"]; ok { - t.Fatalf("modalities should not be set without an API client, got %v", entry["modalities"]) + t.Fatalf("modalities should not be set without capabilities, got %v", entry["modalities"]) } }) - t.Run("uses one timeout budget across capability probes", func(t *testing.T) { - u, err := url.Parse("http://ollama.example") - if err != nil { - t.Fatalf("parse test URL: %v", err) - } - - var mu sync.Mutex - waited := 0 - - client := api.NewClient(u, &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - mu.Lock() - if req.Context().Err() == nil { - waited++ - } - mu.Unlock() - - <-req.Context().Done() - return nil, req.Context().Err() - })}) - - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) - defer cancel() - - models := buildModelEntries(ctx, client, []string{"slow-1", "slow-2"}) - for _, modelID := range []string{"slow-1", "slow-2"} { - entry, _ := models[modelID].(map[string]any) - if entry["name"] != modelID { - t.Fatalf("name for %q = %v, want %q", modelID, entry["name"], modelID) - } - if _, ok := entry["modalities"]; ok { - t.Fatalf("modalities for %q should not be set after probe timeout, got %v", modelID, entry["modalities"]) - } - } - - mu.Lock() - defer mu.Unlock() - if waited != 1 { - t.Fatalf("expected shared timeout to block one probe, waited on %d probes", waited) + t.Run("uses context and output limits from metadata", func(t *testing.T) { + models := buildModelEntries([]LaunchModel{{Name: "glm-5:cloud", ContextLength: 202_752, MaxOutputTokens: 131_072}}) + entry, _ := models["glm-5:cloud"].(map[string]any) + limit, _ := entry["limit"].(map[string]any) + if limit["context"] != 202_752 || limit["output"] != 131_072 { + t.Fatalf("limit = %v, want context/output", limit) } }) } @@ -392,7 +323,7 @@ func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) { expected := cloudModelLimits["glm-4.7"] - if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil { + if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil { t.Fatal(err) } @@ -422,7 +353,7 @@ func TestOpenCodeEdit_SpecialCharsInModelName(t *testing.T) { specialModel := `model-with-"quotes"` - err := o.Edit([]string{specialModel}) + err := o.Edit(testLaunchModels(specialModel)) if err != nil { t.Fatalf("Edit with special chars failed: %v", err) } @@ -515,7 +446,7 @@ func TestOpenCodeResolveContent(t *testing.T) { setTestHome(t, tmpDir) o := &OpenCode{} - if err := o.Edit([]string{"gemma4"}); err != nil { + if err := o.Edit(testLaunchModels("gemma4")); err != nil { t.Fatal(err) } editContent := o.configContent @@ -530,7 +461,7 @@ func TestOpenCodeResolveContent(t *testing.T) { data, _ := json.MarshalIndent(state, "", " ") os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) - got := o.resolveContent("gemma4") + got := o.resolveContent("gemma4", nil) if got != editContent { t.Errorf("resolveContent returned different content than Edit set\ngot: %s\nwant: %s", got, editContent) } @@ -552,7 +483,7 @@ func TestOpenCodeResolveContent(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - content := o.resolveContent("llama3.2") + content := o.resolveContent("llama3.2", nil) if content == "" { t.Fatal("resolveContent returned empty") } @@ -586,7 +517,7 @@ func TestOpenCodeResolveContent(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - content := o.resolveContent("qwen3:32b") + content := o.resolveContent("qwen3:32b", nil) var cfg map[string]any json.Unmarshal([]byte(content), &cfg) @@ -610,7 +541,7 @@ func TestOpenCodeResolveContent(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - content := o.resolveContent("gemma4") + content := o.resolveContent("gemma4", nil) var cfg map[string]any json.Unmarshal([]byte(content), &cfg) @@ -630,11 +561,56 @@ func TestOpenCodeResolveContent(t *testing.T) { setTestHome(t, tmpDir) o := &OpenCode{} - if got := o.resolveContent(""); got != "" { + if got := o.resolveContent("", nil); got != "" { t.Errorf("resolveContent(\"\") = %q, want empty", got) } }) + t.Run("uses run model metadata when Edit was not called", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + stateDir := filepath.Join(tmpDir, ".local", "state", "opencode") + os.MkdirAll(stateDir, 0o755) + state := map[string]any{ + "recent": []any{ + map[string]any{"providerID": "ollama", "modelID": "llama3.2"}, + }, + } + data, _ := json.MarshalIndent(state, "", " ") + os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) + + o := &OpenCode{} + content := o.resolveContent("gemma4", []LaunchModel{ + { + Name: "gemma4", + Capabilities: []model.Capability{model.CapabilityVision}, + ContextLength: 65_536, + MaxOutputTokens: 8_192, + }, + }) + if content == "" { + t.Fatal("resolveContent returned empty") + } + + var cfg map[string]any + json.Unmarshal([]byte(content), &cfg) + provider, _ := cfg["provider"].(map[string]any) + ollama, _ := provider["ollama"].(map[string]any) + cfgModels, _ := ollama["models"].(map[string]any) + entry, _ := cfgModels["gemma4"].(map[string]any) + limit, _ := entry["limit"].(map[string]any) + if limit["context"] != float64(65_536) || limit["output"] != float64(8_192) { + t.Fatalf("limit = %v, want context/output from launch metadata", limit) + } + if _, ok := entry["modalities"].(map[string]any); !ok { + t.Fatalf("modalities should be set from launch metadata, got %v", entry["modalities"]) + } + if cfgModels["llama3.2"] == nil { + t.Fatalf("state model missing from fallback config: %v", cfgModels) + } + }) + t.Run("does not mutate configContent on fallback", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -650,7 +626,7 @@ func TestOpenCodeResolveContent(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - _ = o.resolveContent("llama3.2") + _ = o.resolveContent("llama3.2", nil) if o.configContent != "" { t.Errorf("resolveContent should not mutate configContent, got %q", o.configContent) } @@ -659,19 +635,19 @@ func TestOpenCodeResolveContent(t *testing.T) { func TestBuildInlineConfig(t *testing.T) { t.Run("returns error for empty primary", func(t *testing.T) { - if _, err := buildInlineConfig("", []string{"llama3.2"}); err == nil { + if _, err := buildInlineConfig(LaunchModel{}, testLaunchModels("llama3.2")); err == nil { t.Error("expected error for empty primary") } }) t.Run("returns error for empty models", func(t *testing.T) { - if _, err := buildInlineConfig("llama3.2", nil); err == nil { + if _, err := buildInlineConfig(fallbackLaunchModel("llama3.2"), nil); err == nil { t.Error("expected error for empty models") } }) t.Run("primary differs from first model in list", func(t *testing.T) { - content, err := buildInlineConfig("qwen3:32b", []string{"llama3.2", "qwen3:32b"}) + content, err := buildInlineConfig(fallbackLaunchModel("qwen3:32b"), testLaunchModels("llama3.2", "qwen3:32b")) if err != nil { t.Fatal(err) } @@ -700,7 +676,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - if err := o.Edit([]string{"new-X"}); err != nil { + if err := o.Edit(testLaunchModels("new-X")); err != nil { t.Fatal(err) } @@ -734,7 +710,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - if err := o.Edit([]string{"X", "Y", "Z"}); err != nil { + if err := o.Edit(testLaunchModels("X", "Y", "Z")); err != nil { t.Fatal(err) } @@ -771,7 +747,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - if err := o.Edit([]string{"qwen3:32b"}); err != nil { + if err := o.Edit(testLaunchModels("qwen3:32b")); err != nil { t.Fatal(err) } @@ -808,7 +784,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) { os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644) o := &OpenCode{} - if err := o.Edit([]string{"llama3.2"}); err != nil { + if err := o.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -850,7 +826,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) { // Add 5 new models — should cap at 10 total o := &OpenCode{} - if err := o.Edit([]string{"new-0", "new-1", "new-2", "new-3", "new-4"}); err != nil { + if err := o.Edit(testLaunchModels("new-0", "new-1", "new-2", "new-3", "new-4")); err != nil { t.Fatal(err) } @@ -871,7 +847,7 @@ func TestOpenCodeEdit_BaseURL(t *testing.T) { setTestHome(t, tmpDir) // Default OLLAMA_HOST - o.Edit([]string{"llama3.2"}) + o.Edit(testLaunchModels("llama3.2")) var cfg map[string]any json.Unmarshal([]byte(o.configContent), &cfg) diff --git a/cmd/launch/pi.go b/cmd/launch/pi.go index 05065cc25..7510d35ab 100644 --- a/cmd/launch/pi.go +++ b/cmd/launch/pi.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" "os" "os/exec" "path/filepath" @@ -14,7 +13,6 @@ import ( "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 @@ -28,7 +26,7 @@ const ( func (p *Pi) String() string { return "Pi" } -func (p *Pi) Run(model string, args []string) error { +func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error { fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset) if err := ensureNpmInstalled(); err != nil { return err @@ -183,7 +181,7 @@ func (p *Pi) Paths() []string { return paths } -func (p *Pi) Edit(models []string) error { +func (p *Pi) Edit(models []LaunchModel) error { if len(models) == 0 { return nil } @@ -225,7 +223,7 @@ func (p *Pi) Edit(models []string) error { // 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 + selectedSet[m.Name] = true } // Build new models list: @@ -256,11 +254,9 @@ func (p *Pi) Edit(models []string) error { } // 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)) + if selectedSet[model.Name] { + newModels = append(newModels, createConfig(model)) } } @@ -284,7 +280,7 @@ func (p *Pi) Edit(models []string) error { } settings["defaultProvider"] = "ollama" - settings["defaultModel"] = models[0] + settings["defaultModel"] = models[0].Name settingsData, err := json.MarshalIndent(settings, "", " ") if err != nil { @@ -342,54 +338,27 @@ func hasContextWindow(cfg map[string]any) bool { } } -// createConfig builds Pi model config with capability detection -func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any { +// createConfig builds Pi model config with capability detection. +func createConfig(model LaunchModel) map[string]any { cfg := map[string]any{ - "id": modelID, + "id": model.Name, "_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) { + if model.HasCapability("vision") { cfg["input"] = []string{"text", "image"} } else { cfg["input"] = []string{"text"} } // Set reasoning based on thinking capability - if slices.Contains(resp.Capabilities, model.CapabilityThinking) { + if model.HasCapability("thinking") { 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() + if model.ContextLength > 0 { + cfg["contextWindow"] = model.ContextLength } return cfg diff --git a/cmd/launch/pi_test.go b/cmd/launch/pi_test.go index f289a0471..08f879aa7 100644 --- a/cmd/launch/pi_test.go +++ b/cmd/launch/pi_test.go @@ -1,19 +1,16 @@ package launch import ( - "context" "encoding/json" "fmt" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "runtime" "strings" "testing" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/types/model" ) @@ -138,7 +135,7 @@ exit 0 }) p := &Pi{} - if err := p.Run("ignored", []string{"--version"}); err != nil { + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -181,7 +178,7 @@ exit 0 }) p := &Pi{} - err := p.Run("ignored", nil) + err := p.Run("ignored", nil, nil) if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") { t.Fatalf("expected install cancellation error, got %v", err) } @@ -203,7 +200,7 @@ exit 0 }) p := &Pi{} - if err := p.Run("ignored", []string{"session"}); err != nil { + if err := p.Run("ignored", nil, []string{"session"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -238,7 +235,7 @@ exit 0 seedNpmNoop(t, tmpDir) p := &Pi{} - if err := p.Run("ignored", []string{"doctor"}); err != nil { + if err := p.Run("ignored", nil, []string{"doctor"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -266,7 +263,7 @@ exit 0 p := &Pi{} stderr := captureStderr(t, func() { - if err := p.Run("ignored", []string{"session"}); err != nil { + if err := p.Run("ignored", nil, []string{"session"}); err != nil { t.Fatalf("Run() should continue after web search update failure, got %v", err) } }) @@ -301,7 +298,7 @@ exit 0 p := &Pi{} stderr := captureStderr(t, func() { - if err := p.Run("ignored", []string{"session"}); err != nil { + if err := p.Run("ignored", nil, []string{"session"}); err != nil { t.Fatalf("Run() should continue after web search install failure, got %v", err) } }) @@ -331,7 +328,7 @@ exit 0 p := &Pi{} stderr := captureStderr(t, func() { - if err := p.Run("ignored", []string{"session"}); err != nil { + if err := p.Run("ignored", nil, []string{"session"}); err != nil { t.Fatalf("Run() error = %v", err) } }) @@ -360,7 +357,7 @@ exit 0 seedPiScript(t, tmpDir) p := &Pi{} - err := p.Run("ignored", []string{"session"}) + err := p.Run("ignored", nil, []string{"session"}) if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") { t.Fatalf("expected missing npm error, got %v", err) } @@ -435,7 +432,7 @@ func TestPiEdit(t *testing.T) { } t.Run("returns nil for empty models", func(t *testing.T) { - if err := pi.Edit([]string{}); err != nil { + if err := pi.Edit(testLaunchModels()); err != nil { t.Errorf("Edit([]) error = %v, want nil", err) } }) @@ -444,7 +441,7 @@ func TestPiEdit(t *testing.T) { cleanup() models := []string{"llama3.2", "qwen3:8b"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -497,7 +494,7 @@ func TestPiEdit(t *testing.T) { } models := []string{"new-model"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -550,7 +547,7 @@ func TestPiEdit(t *testing.T) { t.Fatal(err) } - if err := pi.Edit([]string{"glm-5:cloud"}); err != nil { + if err := pi.Edit(testLaunchModels("glm-5:cloud")); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -595,7 +592,7 @@ func TestPiEdit(t *testing.T) { } newModels := []string{"new-model-1", "new-model-2"} - if err := pi.Edit(newModels); err != nil { + if err := pi.Edit(launchModelsFromNames(newModels)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -646,7 +643,7 @@ func TestPiEdit(t *testing.T) { } newModels := []string{"keep-model", "add-model"} - if err := pi.Edit(newModels); err != nil { + if err := pi.Edit(launchModelsFromNames(newModels)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -683,7 +680,7 @@ func TestPiEdit(t *testing.T) { } models := []string{"test-model"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() should not fail with corrupt config, got %v", err) } @@ -732,7 +729,7 @@ func TestPiEdit(t *testing.T) { // Add a new ollama-managed model newModels := []string{"new-ollama-model"} - if err := pi.Edit(newModels); err != nil { + if err := pi.Edit(launchModelsFromNames(newModels)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -793,7 +790,7 @@ func TestPiEdit(t *testing.T) { } models := []string{"llama3.2"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -831,7 +828,7 @@ func TestPiEdit(t *testing.T) { os.MkdirAll(configDir, 0o755) models := []string{"qwen3:8b"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -865,7 +862,7 @@ func TestPiEdit(t *testing.T) { } models := []string{"test-model"} - if err := pi.Edit(models); err != nil { + if err := pi.Edit(launchModelsFromNames(models)); err != nil { t.Fatalf("Edit() should not fail with corrupt settings, got %v", err) } @@ -921,7 +918,7 @@ func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) { t.Fatal(err) } - if err := pi.Edit([]string{"llama3.2"}); err != nil { + if err := pi.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatalf("Edit() error = %v", err) } @@ -1087,19 +1084,7 @@ func TestIsPiOllamaModel(t *testing.T) { func TestCreateConfig(t *testing.T) { t.Run("sets vision input when model has vision capability", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "llava:7b") + cfg := createConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{model.CapabilityVision}}) if cfg["id"] != "llava:7b" { t.Errorf("id = %v, want llava:7b", cfg["id"]) @@ -1114,19 +1099,7 @@ func TestCreateConfig(t *testing.T) { }) t.Run("sets text-only input when model lacks vision", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "llama3.2") + cfg := createConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{model.CapabilityCompletion}}) input, ok := cfg["input"].([]string) if !ok || len(input) != 1 || input[0] != "text" { @@ -1138,39 +1111,15 @@ func TestCreateConfig(t *testing.T) { }) t.Run("sets reasoning when model has thinking capability", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "qwq") + cfg := createConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{model.CapabilityThinking}}) if cfg["reasoning"] != true { t.Error("expected reasoning = true for thinking model") } }) - t.Run("extracts context window from model info", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "llama3.2") + t.Run("sets context window from metadata", func(t *testing.T) { + cfg := createConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072}) if cfg["contextWindow"] != 131072 { t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"]) @@ -1178,19 +1127,11 @@ func TestCreateConfig(t *testing.T) { }) t.Run("handles all capabilities together", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "qwen3-vision") + cfg := createConfig(LaunchModel{ + Name: "qwen3-vision", + Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking}, + ContextLength: 32768, + }) input := cfg["input"].([]string) if len(input) != 2 || input[0] != "text" || input[1] != "image" { @@ -1204,17 +1145,8 @@ func TestCreateConfig(t *testing.T) { } }) - t.Run("returns minimal config when show fails", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, `{"error":"model not found"}`) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "missing-model") + t.Run("returns minimal config when metadata is unavailable", func(t *testing.T) { + cfg := createConfig(LaunchModel{Name: "missing-model"}) if cfg["id"] != "missing-model" { t.Errorf("id = %v, want missing-model", cfg["id"]) @@ -1222,49 +1154,29 @@ func TestCreateConfig(t *testing.T) { if cfg["_launch"] != true { t.Error("expected _launch = true") } - // Should not have capability fields - if _, ok := cfg["input"]; ok { - t.Error("input should not be set when show fails") + // Input defaults to text even when capabilities are unavailable. + input, ok := cfg["input"].([]string) + if !ok || len(input) != 1 || input[0] != "text" { + t.Errorf("input = %v, want [text]", cfg["input"]) } if _, ok := cfg["reasoning"]; ok { - t.Error("reasoning should not be set when show fails") + t.Error("reasoning should not be set when metadata is unavailable") } if _, ok := cfg["contextWindow"]; ok { - t.Error("contextWindow should not be set when show fails") + t.Error("contextWindow should not be set when metadata is unavailable") } }) - t.Run("cloud model falls back to hardcoded context when show fails", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, `{"error":"model not found"}`) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "kimi-k2.5:cloud") + t.Run("cloud model falls back to hardcoded context", func(t *testing.T) { + cfg := createConfig(fallbackLaunchModel("kimi-k2.5:cloud")) if cfg["contextWindow"] != 262_144 { t.Errorf("contextWindow = %v, want 262144", cfg["contextWindow"]) } }) - t.Run("cloud model falls back to hardcoded context when show omits model info", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "glm-5:cloud") + t.Run("cloud model uses hardcoded context when tags omit context", func(t *testing.T) { + cfg := createConfig(fallbackLaunchModel("glm-5:cloud")) if cfg["contextWindow"] != 202_752 { t.Errorf("contextWindow = %v, want 202752", cfg["contextWindow"]) @@ -1272,35 +1184,14 @@ func TestCreateConfig(t *testing.T) { }) t.Run("cloud model with dash suffix falls back to hardcoded context", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, `{"error":"model not found"}`) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "gpt-oss:120b-cloud") + cfg := createConfig(fallbackLaunchModel("gpt-oss:120b-cloud")) if cfg["contextWindow"] != 131_072 { t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"]) } }) t.Run("skips zero context length", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/show" { - fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - client := api.NewClient(u, srv.Client()) - - cfg := createConfig(context.Background(), client, "test-model") + cfg := createConfig(LaunchModel{Name: "test-model", ContextLength: 0}) if _, ok := cfg["contextWindow"]; ok { t.Error("contextWindow should not be set for zero value") diff --git a/cmd/launch/poolside.go b/cmd/launch/poolside.go index 4f64b4d11..72d4e6ee7 100644 --- a/cmd/launch/poolside.go +++ b/cmd/launch/poolside.go @@ -29,7 +29,7 @@ func (p *Poolside) args(model string, extra []string) []string { return args } -func (p *Poolside) Run(model string, args []string) error { +func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error { if poolsideGOOS == "windows" { return poolsideUnsupportedError() } diff --git a/cmd/launch/poolside_test.go b/cmd/launch/poolside_test.go index 9608aefe6..f66172500 100644 --- a/cmd/launch/poolside_test.go +++ b/cmd/launch/poolside_test.go @@ -51,7 +51,7 @@ func TestPoolsideRunSetsOllamaEnv(t *testing.T) { t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") p := &Poolside{} - if err := p.Run("qwen3.5", []string{"session"}); err != nil { + if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -78,7 +78,7 @@ func TestPoolsideRunWindowsUnsupported(t *testing.T) { t.Cleanup(func() { poolsideGOOS = prev }) p := &Poolside{} - err := p.Run("kimi-k2.6:cloud", nil) + err := p.Run("kimi-k2.6:cloud", nil, nil) if err == nil { t.Fatal("expected Windows unsupported error") } diff --git a/cmd/launch/runner_exec_only_test.go b/cmd/launch/runner_exec_only_test.go index 66d3c67fe..06a5d6554 100644 --- a/cmd/launch/runner_exec_only_test.go +++ b/cmd/launch/runner_exec_only_test.go @@ -84,7 +84,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) { t.Setenv("PATH", binDir) configPath := tt.checkPath(home) - if err := tt.runner.Run("llama3.2", nil); err != nil { + if err := tt.runner.Run("llama3.2", nil, nil); err != nil { t.Fatalf("Run returned error: %v", err) } if _, err := os.Stat(configPath); !os.IsNotExist(err) { diff --git a/cmd/launch/test_config_helpers_test.go b/cmd/launch/test_config_helpers_test.go index e73a51932..d68e750b7 100644 --- a/cmd/launch/test_config_helpers_test.go +++ b/cmd/launch/test_config_helpers_test.go @@ -41,6 +41,10 @@ func setTestHome(t *testing.T, dir string) { setLaunchTestHome(t, dir) } +func testLaunchModels(names ...string) []LaunchModel { + return launchModelsFromNames(names) +} + func SaveIntegration(appName string, models []string) error { return config.SaveIntegration(appName, models) } diff --git a/cmd/launch/vscode.go b/cmd/launch/vscode.go index 495f00f5e..60138b49b 100644 --- a/cmd/launch/vscode.go +++ b/cmd/launch/vscode.go @@ -126,7 +126,7 @@ const ( minVSCodeVersion = "1.113" ) -func (v *VSCode) Run(model string, args []string) error { +func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error { v.checkVSCodeVersion() v.checkCopilotChatVersion() @@ -238,7 +238,7 @@ func (v *VSCode) Paths() []string { return nil } -func (v *VSCode) Edit(models []string) error { +func (v *VSCode) Edit(models []LaunchModel) error { if len(models) == 0 { return nil } diff --git a/cmd/launch/vscode_test.go b/cmd/launch/vscode_test.go index 4a3dc2cb0..7703bbd20 100644 --- a/cmd/launch/vscode_test.go +++ b/cmd/launch/vscode_test.go @@ -113,7 +113,7 @@ func TestVSCodeEdit(t *testing.T) { os.WriteFile(clmPath, []byte(tt.setup), 0o644) } - if err := v.Edit(tt.models); err != nil { + if err := v.Edit(launchModelsFromNames(tt.models)); err != nil { t.Fatal(err) } @@ -134,7 +134,7 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) { os.MkdirAll(filepath.Dir(settingsPath), 0o755) os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644) - if err := v.Edit([]string{"llama3.2"}); err != nil { + if err := v.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) } @@ -180,7 +180,7 @@ func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) { t.Fatal(err) } - if err := v.Edit([]string{"llama3.2"}); err != nil { + if err := v.Edit(testLaunchModels("llama3.2")); err != nil { t.Fatal(err) }