launch: enriched model inventory (#16230)

This commit is contained in:
Parth Sareen
2026-05-21 11:57:20 -07:00
committed by GitHub
parent 4b2d529966
commit 91c8e5e1a8
34 changed files with 836 additions and 1041 deletions

View File

@@ -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")

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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" {

View File

@@ -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")

View File

@@ -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,
})

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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])
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}