add ability to disable cloud (#14221)

* add ability to disable cloud

Users can now easily opt-out of cloud inference and web search by
setting

```
"disable_ollama_cloud": true
```

in their `~/.ollama/server.json` settings file. After a setting update,
the server must be restarted.

Alternatively, setting the environment variable `OLLAMA_NO_CLOUD=1` will
also disable cloud features. While users previously were able to avoid
cloud models by not pulling or `ollama run`ing them, this gives them an
easy way to enforce that decision. Any attempt to run a cloud model when
cloud is disabled will fail.

The app's old "airplane mode" setting, which did a similar thing for
hiding cloud models within the app is now unified with this new cloud
disabled mode. That setting has been replaced with a "Cloud" toggle,
which behind the scenes edits `server.json` and then restarts the
server.

* gate cloud models across TUI and launch flows when cloud is disabled

Block cloud models from being selected, launched, or written to
integration configs when cloud mode is turned off:

- TUI main menu: open model picker instead of launching with a
  disabled cloud model
- cmd.go: add IsCloudModelDisabled checks for all Selection* paths
- LaunchCmd: filter cloud models from saved Editor configs before
  launch, fall through to picker if none remain
- Editor Run() methods (droid, opencode, openclaw): filter cloud
  models before calling Edit() and persist the cleaned list
- Export SaveIntegration, remove SaveIntegrationModel wrapper that
  was accumulating models instead of replacing them

* rename saveIntegration to SaveIntegration in config.go and tests

* cmd/config: add --model guarding and empty model list fixes

* Update docs/faq.mdx

Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>

* Update internal/cloud/policy.go

Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>

* Update internal/cloud/policy.go

Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>

* Update server/routes.go

Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>

* Revert "Update internal/cloud/policy.go"

This reverts commit 8bff8615f9.

Since this error shows up in other integrations, we want it to be
prefixed with Ollama

* rename cloud status

* more status renaming

* fix tests that weren't updated after rename

---------

Co-authored-by: ParthSareen <parth.sareen@ollama.com>
Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>
This commit is contained in:
Devon Rifkin
2026-02-12 15:47:00 -08:00
committed by GitHub
parent 598b74d42c
commit 948de6bbd2
56 changed files with 2026 additions and 155 deletions

View File

@@ -140,7 +140,7 @@ func TestClaudeModelEnvVars(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
saveIntegration("claude", []string{"qwen3:8b"})
SaveIntegration("claude", []string{"qwen3:8b"})
saveAliases("claude", map[string]string{"primary": "qwen3:8b"})
got := envMap(c.modelEnvVars("qwen3:8b"))
@@ -162,7 +162,7 @@ func TestClaudeModelEnvVars(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
saveIntegration("claude", []string{"llama3.2:70b"})
SaveIntegration("claude", []string{"llama3.2:70b"})
saveAliases("claude", map[string]string{
"primary": "llama3.2:70b",
"fast": "llama3.2:8b",
@@ -187,7 +187,7 @@ func TestClaudeModelEnvVars(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
saveIntegration("claude", []string{"saved-model"})
SaveIntegration("claude", []string{"saved-model"})
saveAliases("claude", map[string]string{"primary": "saved-model"})
got := envMap(c.modelEnvVars("different-model"))

View File

@@ -56,8 +56,8 @@ func migrateConfig() (bool, error) {
return false, err
}
var js json.RawMessage
if err := json.Unmarshal(oldData, &js); err != nil {
// Ignore legacy files with invalid JSON and continue startup.
if !json.Valid(oldData) {
return false, nil
}
@@ -126,7 +126,7 @@ func save(cfg *config) error {
return writeWithBackup(path, data)
}
func saveIntegration(appName string, models []string) error {
func SaveIntegration(appName string, models []string) error {
if appName == "" {
return errors.New("app name cannot be empty")
}

View File

@@ -85,7 +85,7 @@ func TestSaveAliases_PreservesModels(t *testing.T) {
setTestHome(t, tmpDir)
// First save integration with models
if err := saveIntegration("claude", []string{"model1", "model2"}); err != nil {
if err := SaveIntegration("claude", []string{"model1", "model2"}); err != nil {
t.Fatalf("failed to save integration: %v", err)
}
@@ -604,7 +604,7 @@ func TestModelsAndAliasesMustStayInSync(t *testing.T) {
}
// Save integration with same model (this is the pattern we use)
if err := saveIntegration("claude", []string{"model-a"}); err != nil {
if err := SaveIntegration("claude", []string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -619,7 +619,7 @@ func TestModelsAndAliasesMustStayInSync(t *testing.T) {
setTestHome(t, tmpDir)
// Simulate out-of-sync state (like manual edit or bug)
if err := saveIntegration("claude", []string{"old-model"}); err != nil {
if err := SaveIntegration("claude", []string{"old-model"}); err != nil {
t.Fatal(err)
}
if err := saveAliases("claude", map[string]string{"primary": "new-model"}); err != nil {
@@ -634,7 +634,7 @@ func TestModelsAndAliasesMustStayInSync(t *testing.T) {
}
// The fix: when updating aliases, also update models
if err := saveIntegration("claude", []string{loaded.Aliases["primary"]}); err != nil {
if err := SaveIntegration("claude", []string{loaded.Aliases["primary"]}); err != nil {
t.Fatal(err)
}
@@ -650,7 +650,7 @@ func TestModelsAndAliasesMustStayInSync(t *testing.T) {
setTestHome(t, tmpDir)
// Initial state
if err := saveIntegration("claude", []string{"initial-model"}); err != nil {
if err := SaveIntegration("claude", []string{"initial-model"}); err != nil {
t.Fatal(err)
}
if err := saveAliases("claude", map[string]string{"primary": "initial-model"}); err != nil {
@@ -662,7 +662,7 @@ func TestModelsAndAliasesMustStayInSync(t *testing.T) {
if err := saveAliases("claude", newAliases); err != nil {
t.Fatal(err)
}
if err := saveIntegration("claude", []string{newAliases["primary"]}); err != nil {
if err := SaveIntegration("claude", []string{newAliases["primary"]}); err != nil {
t.Fatal(err)
}

View File

@@ -27,7 +27,7 @@ func TestIntegrationConfig(t *testing.T) {
t.Run("save and load round-trip", func(t *testing.T) {
models := []string{"llama3.2", "mistral", "qwen2.5"}
if err := saveIntegration("claude", models); err != nil {
if err := SaveIntegration("claude", models); err != nil {
t.Fatal(err)
}
@@ -48,7 +48,7 @@ func TestIntegrationConfig(t *testing.T) {
t.Run("save and load aliases", func(t *testing.T) {
models := []string{"llama3.2"}
if err := saveIntegration("claude", models); err != nil {
if err := SaveIntegration("claude", models); err != nil {
t.Fatal(err)
}
aliases := map[string]string{
@@ -74,14 +74,14 @@ func TestIntegrationConfig(t *testing.T) {
})
t.Run("saveIntegration preserves aliases", func(t *testing.T) {
if err := saveIntegration("claude", []string{"model-a"}); err != nil {
if err := SaveIntegration("claude", []string{"model-a"}); err != nil {
t.Fatal(err)
}
if err := saveAliases("claude", map[string]string{"primary": "model-a", "fast": "model-small"}); err != nil {
t.Fatal(err)
}
if err := saveIntegration("claude", []string{"model-b"}); err != nil {
if err := SaveIntegration("claude", []string{"model-b"}); err != nil {
t.Fatal(err)
}
config, err := loadIntegration("claude")
@@ -94,7 +94,7 @@ func TestIntegrationConfig(t *testing.T) {
})
t.Run("defaultModel returns first model", func(t *testing.T) {
saveIntegration("codex", []string{"model-a", "model-b"})
SaveIntegration("codex", []string{"model-a", "model-b"})
config, _ := loadIntegration("codex")
defaultModel := ""
@@ -118,7 +118,7 @@ func TestIntegrationConfig(t *testing.T) {
})
t.Run("app name is case-insensitive", func(t *testing.T) {
saveIntegration("Claude", []string{"model-x"})
SaveIntegration("Claude", []string{"model-x"})
config, err := loadIntegration("claude")
if err != nil {
@@ -134,8 +134,8 @@ func TestIntegrationConfig(t *testing.T) {
})
t.Run("multiple integrations in single file", func(t *testing.T) {
saveIntegration("app1", []string{"model-1"})
saveIntegration("app2", []string{"model-2"})
SaveIntegration("app1", []string{"model-1"})
SaveIntegration("app2", []string{"model-2"})
config1, _ := loadIntegration("app1")
config2, _ := loadIntegration("app2")
@@ -172,8 +172,8 @@ func TestListIntegrations(t *testing.T) {
})
t.Run("returns all saved integrations", func(t *testing.T) {
saveIntegration("claude", []string{"model-1"})
saveIntegration("droid", []string{"model-2"})
SaveIntegration("claude", []string{"model-1"})
SaveIntegration("droid", []string{"model-2"})
configs, err := listIntegrations()
if err != nil {
@@ -261,7 +261,7 @@ func TestSaveIntegration_NilModels(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := saveIntegration("test", nil); err != nil {
if err := SaveIntegration("test", nil); err != nil {
t.Fatalf("saveIntegration with nil models failed: %v", err)
}
@@ -281,7 +281,7 @@ func TestSaveIntegration_EmptyAppName(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
err := saveIntegration("", []string{"model"})
err := SaveIntegration("", []string{"model"})
if err == nil {
t.Error("expected error for empty app name, got nil")
}
@@ -511,7 +511,7 @@ func TestMigrateConfig(t *testing.T) {
os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{"claude":{"models":["llama3.2"]}}}`), 0o644)
// load triggers migration, then save should write to new path
if err := saveIntegration("codex", []string{"qwen2.5"}); err != nil {
if err := SaveIntegration("codex", []string{"qwen2.5"}); err != nil {
t.Fatal(err)
}

View File

@@ -3,6 +3,7 @@ package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@@ -51,6 +52,16 @@ func (d *Droid) Run(model string, args []string) error {
if config, err := loadIntegration("droid"); err == nil && len(config.Models) > 0 {
models = config.Models
}
var err error
models, err = resolveEditorModels("droid", models, func() ([]string, error) {
return selectModels(context.Background(), "droid", "")
})
if errors.Is(err, errCancelled) {
return nil
}
if err != nil {
return err
}
if err := d.Edit(models); err != nil {
return fmt.Errorf("setup failed: %w", err)
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"maps"
"net/http"
"os"
"os/exec"
"runtime"
@@ -13,6 +14,7 @@ import (
"time"
"github.com/ollama/ollama/api"
internalcloud "github.com/ollama/ollama/internal/cloud"
"github.com/ollama/ollama/progress"
"github.com/spf13/cobra"
)
@@ -234,6 +236,11 @@ func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (stri
existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""})
}
cloudDisabled, _ := cloudStatusDisabled(ctx, client)
if cloudDisabled {
existing = filterCloudModels(existing)
}
lastModel := LastModel()
var preChecked []string
if lastModel != "" {
@@ -242,6 +249,10 @@ func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (stri
items, _, existingModels, cloudModels := buildModelList(existing, preChecked, lastModel)
if cloudDisabled {
items = filterCloudItems(items)
}
if len(items) == 0 {
return "", fmt.Errorf("no models available, run 'ollama pull <model>' first")
}
@@ -395,6 +406,11 @@ func selectModelsWithSelectors(ctx context.Context, name, current string, single
existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""})
}
cloudDisabled, _ := cloudStatusDisabled(ctx, client)
if cloudDisabled {
existing = filterCloudModels(existing)
}
var preChecked []string
if saved, err := loadIntegration(name); err == nil {
preChecked = saved.Models
@@ -404,6 +420,10 @@ func selectModelsWithSelectors(ctx context.Context, name, current string, single
items, preChecked, existingModels, cloudModels := buildModelList(existing, preChecked, current)
if cloudDisabled {
items = filterCloudItems(items)
}
if len(items) == 0 {
return nil, fmt.Errorf("no models available")
}
@@ -510,8 +530,17 @@ func listModels(ctx context.Context) ([]ModelItem, map[string]bool, map[string]b
})
}
cloudDisabled, _ := cloudStatusDisabled(ctx, client)
if cloudDisabled {
existing = filterCloudModels(existing)
}
items, _, existingModels, cloudModels := buildModelList(existing, nil, "")
if cloudDisabled {
items = filterCloudItems(items)
}
if len(items) == 0 {
return nil, nil, nil, nil, fmt.Errorf("no models available, run 'ollama pull <model>' first")
}
@@ -540,6 +569,9 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
if len(selectedCloudModels) == 0 {
return nil
}
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
}
user, err := client.Whoami(ctx)
if err == nil && user != nil && user.Name != "" {
@@ -672,25 +704,6 @@ func LaunchIntegrationWithModel(name, modelName string) error {
return runIntegration(name, modelName, nil)
}
// SaveIntegrationModel saves the model for an integration.
func SaveIntegrationModel(name, modelName string) error {
// Load existing models and prepend the new one
var models []string
if existing, err := loadIntegration(name); err == nil && len(existing.Models) > 0 {
models = existing.Models
// Remove the model if it already exists
for i, m := range models {
if m == modelName {
models = append(models[:i], models[i+1:]...)
break
}
}
}
// Prepend the new model
models = append([]string{modelName}, models...)
return saveIntegration(name, models)
}
// SaveAndEditIntegration saves the models for an Editor integration and runs its Edit method
// to write the integration's config files.
func SaveAndEditIntegration(name string, models []string) error {
@@ -698,7 +711,7 @@ func SaveAndEditIntegration(name string, models []string) error {
if !ok {
return fmt.Errorf("unknown integration: %s", name)
}
if err := saveIntegration(name, models); err != nil {
if err := SaveIntegration(name, models); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
if editor, isEditor := r.(Editor); isEditor {
@@ -709,6 +722,29 @@ func SaveAndEditIntegration(name string, models []string) error {
return nil
}
// resolveEditorModels filters out cloud-disabled models before editor launch.
// If no models remain, it invokes picker to collect a valid replacement list.
func resolveEditorModels(name string, models []string, picker func() ([]string, error)) ([]string, error) {
filtered := filterDisabledCloudModels(models)
if len(filtered) != len(models) {
if err := SaveIntegration(name, filtered); err != nil {
return nil, fmt.Errorf("failed to save: %w", err)
}
}
if len(filtered) > 0 {
return filtered, nil
}
selected, err := picker()
if err != nil {
return nil, err
}
if err := SaveIntegration(name, selected); err != nil {
return nil, fmt.Errorf("failed to save: %w", err)
}
return selected, nil
}
// ConfigureIntegrationWithSelectors allows the user to select/change the model for an integration using custom selectors.
func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single SingleSelector, multi MultiSelector) error {
r, ok := integrations[name]
@@ -743,7 +779,7 @@ func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single
}
}
if err := saveIntegration(name, models); err != nil {
if err := SaveIntegration(name, models); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
@@ -837,6 +873,10 @@ Examples:
return fmt.Errorf("unknown integration: %s", name)
}
if modelFlag != "" && IsCloudModelDisabled(cmd.Context(), modelFlag) {
modelFlag = ""
}
// Handle AliasConfigurer integrations (claude, codex)
if ac, ok := r.(AliasConfigurer); ok {
client, err := api.ClientFromEnvironment()
@@ -864,7 +904,7 @@ Examples:
model = cfg.Models[0]
// AliasConfigurer integrations use single model; sanitize if multiple
if len(cfg.Models) > 1 {
_ = saveIntegration(name, []string{model})
_ = SaveIntegration(name, []string{model})
}
}
}
@@ -875,8 +915,12 @@ Examples:
}
// Validate saved model still exists
cloudCleared := false
if model != "" && modelFlag == "" {
if _, err := client.Show(cmd.Context(), &api.ShowRequest{Model: model}); err != nil {
if disabled, _ := cloudStatusDisabled(cmd.Context(), client); disabled && isCloudModelName(model) {
model = ""
cloudCleared = true
} else if _, err := client.Show(cmd.Context(), &api.ShowRequest{Model: model}); err != nil {
fmt.Fprintf(os.Stderr, "%sConfigured model %q not found%s\n\n", ansiGray, model, ansiReset)
if err := ShowOrPull(cmd.Context(), client, model); err != nil {
model = ""
@@ -886,7 +930,7 @@ Examples:
// If no valid model or --config flag, show picker
if model == "" || configFlag {
aliases, _, err := ac.ConfigureAliases(cmd.Context(), model, existingAliases, configFlag)
aliases, _, err := ac.ConfigureAliases(cmd.Context(), model, existingAliases, configFlag || cloudCleared)
if errors.Is(err, errCancelled) {
return nil
}
@@ -908,7 +952,7 @@ Examples:
if err := syncAliases(cmd.Context(), client, ac, name, model, existingAliases); err != nil {
fmt.Fprintf(os.Stderr, "%sWarning: Could not sync aliases: %v%s\n", ansiGray, err, ansiReset)
}
if err := saveIntegration(name, []string{model}); err != nil {
if err := SaveIntegration(name, []string{model}); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
@@ -946,8 +990,35 @@ Examples:
}
}
}
models = filterDisabledCloudModels(models)
if len(models) == 0 {
var err error
models, err = selectModels(cmd.Context(), name, "")
if errors.Is(err, errCancelled) {
return nil
}
if err != nil {
return err
}
}
} else if saved, err := loadIntegration(name); err == nil && len(saved.Models) > 0 && !configFlag {
return runIntegration(name, saved.Models[0], passArgs)
savedModels := filterDisabledCloudModels(saved.Models)
if len(savedModels) != len(saved.Models) {
_ = SaveIntegration(name, savedModels)
}
if len(savedModels) == 0 {
// All saved models were cloud — fall through to picker
models, err = selectModels(cmd.Context(), name, "")
if errors.Is(err, errCancelled) {
return nil
}
if err != nil {
return err
}
} else {
models = savedModels
return runIntegration(name, models[0], passArgs)
}
} else {
var err error
models, err = selectModels(cmd.Context(), name, "")
@@ -974,7 +1045,7 @@ Examples:
}
}
if err := saveIntegration(name, models); err != nil {
if err := SaveIntegration(name, models); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
@@ -1048,7 +1119,7 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
continue
}
items = append(items, rec)
if strings.HasSuffix(rec.Name, ":cloud") {
if isCloudModelName(rec.Name) {
cloudModels[rec.Name] = true
}
}
@@ -1153,7 +1224,55 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
return items, preChecked, existingModels, cloudModels
}
// isCloudModel checks if a model is a cloud model using the Show API.
// IsCloudModelDisabled reports whether the given model name looks like a cloud
// model and cloud features are currently disabled on the server.
func IsCloudModelDisabled(ctx context.Context, name string) bool {
if !isCloudModelName(name) {
return false
}
client, err := api.ClientFromEnvironment()
if err != nil {
return false
}
disabled, _ := cloudStatusDisabled(ctx, client)
return disabled
}
func isCloudModelName(name string) bool {
return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud")
}
func filterCloudModels(existing []modelInfo) []modelInfo {
filtered := existing[:0]
for _, m := range existing {
if !m.Remote {
filtered = append(filtered, m)
}
}
return filtered
}
// filterDisabledCloudModels removes cloud models from a list when cloud is disabled.
func filterDisabledCloudModels(models []string) []string {
var filtered []string
for _, m := range models {
if !IsCloudModelDisabled(context.Background(), m) {
filtered = append(filtered, m)
}
}
return filtered
}
func filterCloudItems(items []ModelItem) []ModelItem {
filtered := items[:0]
for _, item := range items {
if !isCloudModelName(item.Name) {
filtered = append(filtered, item)
}
}
return filtered
}
func isCloudModel(ctx context.Context, client *api.Client, name string) bool {
if client == nil {
return false
@@ -1183,6 +1302,11 @@ func GetModelItems(ctx context.Context) ([]ModelItem, map[string]bool) {
existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""})
}
cloudDisabled, _ := cloudStatusDisabled(ctx, client)
if cloudDisabled {
existing = filterCloudModels(existing)
}
lastModel := LastModel()
var preChecked []string
if lastModel != "" {
@@ -1191,9 +1315,25 @@ func GetModelItems(ctx context.Context) ([]ModelItem, map[string]bool) {
items, _, existingModels, _ := buildModelList(existing, preChecked, lastModel)
if cloudDisabled {
items = filterCloudItems(items)
}
return items, existingModels
}
func cloudStatusDisabled(ctx context.Context, client *api.Client) (disabled bool, known bool) {
status, err := client.CloudStatusExperimental(ctx)
if err != nil {
var statusErr api.StatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
return false, false
}
return false, false
}
return status.Cloud.Disabled, true
}
func pullModel(ctx context.Context, client *api.Client, model string) error {
p := progress.NewProgress(os.Stderr)
defer p.Stop()

View File

@@ -16,6 +16,28 @@ import (
"github.com/spf13/cobra"
)
type stubEditorRunner struct {
edited [][]string
ranModel string
}
func (s *stubEditorRunner) Run(model string, args []string) error {
s.ranModel = model
return nil
}
func (s *stubEditorRunner) String() string { return "StubEditor" }
func (s *stubEditorRunner) Paths() []string { return nil }
func (s *stubEditorRunner) Edit(models []string) error {
cloned := append([]string(nil), models...)
s.edited = append(s.edited, cloned)
return nil
}
func (s *stubEditorRunner) Models() []string { return nil }
func TestIntegrationLookup(t *testing.T) {
tests := []struct {
name string
@@ -149,6 +171,10 @@ func TestLaunchCmd_TUICallback(t *testing.T) {
})
t.Run("integration arg bypasses TUI", func(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
tuiCalled := false
mockTUI := func(cmd *cobra.Command) {
tuiCalled = true
@@ -680,7 +706,7 @@ func TestEditorIntegration_SavedConfigSkipsSelection(t *testing.T) {
setTestHome(t, tmpDir)
// Save a config for opencode so it looks like a previous launch
if err := saveIntegration("opencode", []string{"llama3.2"}); err != nil {
if err := SaveIntegration("opencode", []string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -697,6 +723,137 @@ func TestEditorIntegration_SavedConfigSkipsSelection(t *testing.T) {
}
}
func TestResolveEditorLaunchModels_PicksWhenAllFiltered(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
pickerCalled := false
models, err := resolveEditorModels("opencode", []string{"glm-5:cloud"}, func() ([]string, error) {
pickerCalled = true
return []string{"llama3.2"}, nil
})
if err != nil {
t.Fatalf("resolveEditorLaunchModels returned error: %v", err)
}
if !pickerCalled {
t.Fatal("expected model picker to be called when all models are filtered")
}
if diff := cmp.Diff([]string{"llama3.2"}, models); diff != "" {
t.Fatalf("resolved models mismatch (-want +got):\n%s", diff)
}
saved, err := loadIntegration("opencode")
if err != nil {
t.Fatalf("failed to reload integration config: %v", err)
}
if diff := cmp.Diff([]string{"llama3.2"}, saved.Models); diff != "" {
t.Fatalf("saved models mismatch (-want +got):\n%s", diff)
}
}
func TestResolveEditorLaunchModels_FiltersAndSkipsPickerWhenLocalRemains(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
pickerCalled := false
models, err := resolveEditorModels("droid", []string{"llama3.2", "glm-5:cloud"}, func() ([]string, error) {
pickerCalled = true
return []string{"qwen3:8b"}, nil
})
if err != nil {
t.Fatalf("resolveEditorLaunchModels returned error: %v", err)
}
if pickerCalled {
t.Fatal("picker should not be called when a local model remains")
}
if diff := cmp.Diff([]string{"llama3.2"}, models); diff != "" {
t.Fatalf("resolved models mismatch (-want +got):\n%s", diff)
}
saved, err := loadIntegration("droid")
if err != nil {
t.Fatalf("failed to reload integration config: %v", err)
}
if diff := cmp.Diff([]string{"llama3.2"}, saved.Models); diff != "" {
t.Fatalf("saved models mismatch (-want +got):\n%s", diff)
}
}
func TestLaunchCmd_ModelFlagFiltersDisabledCloudFromSavedConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := SaveIntegration("stubeditor", []string{"glm-5:cloud"}); err != nil {
t.Fatalf("failed to seed saved config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`)
case "/api/show":
fmt.Fprintf(w, `{"model":"llama3.2"}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
stub := &stubEditorRunner{}
old, existed := integrations["stubeditor"]
integrations["stubeditor"] = stub
defer func() {
if existed {
integrations["stubeditor"] = old
} else {
delete(integrations, "stubeditor")
}
}()
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {})
cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("launch command failed: %v", err)
}
saved, err := loadIntegration("stubeditor")
if err != nil {
t.Fatalf("failed to reload integration config: %v", err)
}
if diff := cmp.Diff([]string{"llama3.2"}, saved.Models); diff != "" {
t.Fatalf("saved models mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff([][]string{{"llama3.2"}}, stub.edited); diff != "" {
t.Fatalf("editor models mismatch (-want +got):\n%s", diff)
}
if stub.ranModel != "llama3.2" {
t.Fatalf("expected launch to run with llama3.2, got %q", stub.ranModel)
}
}
func TestAliasConfigurerInterface(t *testing.T) {
t.Run("claude implements AliasConfigurer", func(t *testing.T) {
claude := &Claude{}
@@ -1234,7 +1391,7 @@ func TestIntegrationModels(t *testing.T) {
})
t.Run("returns all saved models", func(t *testing.T) {
if err := saveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {
if err := SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {
t.Fatal(err)
}
got := IntegrationModels("droid")

View File

@@ -2,7 +2,9 @@ package config
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -32,6 +34,16 @@ func (c *Openclaw) Run(model string, args []string) error {
} else if config, err := loadIntegration("clawdbot"); err == nil && len(config.Models) > 0 {
models = config.Models
}
var err error
models, err = resolveEditorModels("openclaw", models, func() ([]string, error) {
return selectModels(context.Background(), "openclaw", "")
})
if errors.Is(err, errCancelled) {
return nil
}
if err != nil {
return err
}
if err := c.Edit(models); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
@@ -58,7 +70,7 @@ func (c *Openclaw) Run(model string, args []string) error {
cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf)
cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf)
err := cmd.Run()
err = cmd.Run()
if err != nil && strings.Contains(outputBuf.String(), "Gateway already running") {
fmt.Fprintf(os.Stderr, "%sOpenClaw has been configured with Ollama. Gateway is already running.%s\n", ansiGreen, ansiReset)
return nil

View File

@@ -3,6 +3,7 @@ package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
@@ -51,6 +52,16 @@ func (o *OpenCode) Run(model string, args []string) error {
if config, err := loadIntegration("opencode"); err == nil && len(config.Models) > 0 {
models = config.Models
}
var err error
models, err = resolveEditorModels("opencode", models, func() ([]string, error) {
return selectModels(context.Background(), "opencode", "")
})
if errors.Is(err, errCancelled) {
return nil
}
if err != nil {
return err
}
if err := o.Edit(models); err != nil {
return fmt.Errorf("setup failed: %w", err)
}