From 948de6bbd2d07e1050d91c2422c3ee7af2d591da Mon Sep 17 00:00:00 2001 From: Devon Rifkin Date: Thu, 12 Feb 2026 15:47:00 -0800 Subject: [PATCH] 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 * Update internal/cloud/policy.go Co-authored-by: Jeffrey Morgan * Update internal/cloud/policy.go Co-authored-by: Jeffrey Morgan * Update server/routes.go Co-authored-by: Jeffrey Morgan * Revert "Update internal/cloud/policy.go" This reverts commit 8bff8615f9b5751fc5c0d1273b07c9de651e07f9. 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 Co-authored-by: Jeffrey Morgan --- api/client.go | 10 ++ api/types.go | 10 ++ app/server/server.go | 10 ++ app/server/server_test.go | 73 +++++++- app/store/cloud_config.go | 128 ++++++++++++++ app/store/cloud_config_test.go | 130 ++++++++++++++ app/store/database.go | 58 ++++++- app/store/migration_test.go | 59 +++++++ app/store/store.go | 37 +++- app/store/test_home_test.go | 11 ++ app/tools/cloud_policy.go | 35 ++++ app/tools/cloud_policy_test.go | 73 ++++++++ app/tools/web_fetch.go | 4 + app/tools/web_search.go | 4 + app/ui/app/codegen/gotypes.gen.ts | 2 - app/ui/app/src/api.ts | 41 +++++ app/ui/app/src/components/ChatForm.tsx | 34 ++-- app/ui/app/src/components/ModelPicker.tsx | 12 +- app/ui/app/src/components/Settings.tsx | 123 ++++++++++---- app/ui/app/src/hooks/useChats.ts | 8 +- app/ui/app/src/hooks/useCloudStatus.ts | 20 +++ app/ui/app/src/hooks/useModels.ts | 8 +- app/ui/app/src/hooks/useSelectedModel.ts | 33 ++-- app/ui/app/src/hooks/useSettings.ts | 2 - app/ui/app/src/routes/__root.tsx | 3 + app/ui/app/src/utils/mergeModels.test.ts | 4 +- app/ui/app/src/utils/mergeModels.ts | 4 +- app/ui/ui.go | 37 ++++ app/ui/ui_test.go | 101 +++++++++++ cmd/cmd.go | 24 ++- cmd/config/claude_test.go | 6 +- cmd/config/config.go | 6 +- cmd/config/config_cloud_test.go | 12 +- cmd/config/config_test.go | 26 +-- cmd/config/droid.go | 11 ++ cmd/config/integrations.go | 198 ++++++++++++++++++---- cmd/config/integrations_test.go | 161 +++++++++++++++++- cmd/config/openclaw.go | 14 +- cmd/config/opencode.go | 11 ++ cmd/tui/tui.go | 39 ++++- docs/cloud.mdx | 4 + docs/faq.mdx | 20 +++ envconfig/config.go | 94 ++++++++++ envconfig/config_test.go | 80 +++++++++ envconfig/test_home_test.go | 10 ++ internal/cloud/policy.go | 25 +++ internal/cloud/policy_test.go | 85 ++++++++++ internal/cloud/test_home_test.go | 14 ++ server/aliases.go | 24 ++- server/routes.go | 41 +++++ server/routes_aliases_test.go | 57 ++++++- server/routes_cloud_test.go | 94 ++++++++++ server/test_home_test.go | 14 ++ x/cmd/run.go | 27 +++ x/tools/webfetch.go | 5 + x/tools/websearch.go | 5 + 56 files changed, 2026 insertions(+), 155 deletions(-) create mode 100644 app/store/cloud_config.go create mode 100644 app/store/cloud_config_test.go create mode 100644 app/store/test_home_test.go create mode 100644 app/tools/cloud_policy.go create mode 100644 app/tools/cloud_policy_test.go create mode 100644 app/ui/app/src/hooks/useCloudStatus.ts create mode 100644 envconfig/test_home_test.go create mode 100644 internal/cloud/policy.go create mode 100644 internal/cloud/policy_test.go create mode 100644 internal/cloud/test_home_test.go create mode 100644 server/routes_cloud_test.go create mode 100644 server/test_home_test.go diff --git a/api/client.go b/api/client.go index a09aa33bc..f56639c9a 100644 --- a/api/client.go +++ b/api/client.go @@ -449,6 +449,16 @@ func (c *Client) Version(ctx context.Context) (string, error) { return version.Version, nil } +// CloudStatusExperimental returns whether cloud features are disabled on the server. +func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse, error) { + var status StatusResponse + if err := c.do(ctx, http.MethodGet, "/api/status", nil, &status); err != nil { + return nil, err + } + + return &status, nil +} + // Signout will signout a client for a local ollama server. func (c *Client) Signout(ctx context.Context) error { return c.do(ctx, http.MethodPost, "/api/signout", nil, nil) diff --git a/api/types.go b/api/types.go index 1a728e09b..82caf17dc 100644 --- a/api/types.go +++ b/api/types.go @@ -834,6 +834,16 @@ type TokenResponse struct { Token string `json:"token"` } +type CloudStatus struct { + Disabled bool `json:"disabled"` + Source string `json:"source"` +} + +// StatusResponse is the response from [Client.CloudStatusExperimental]. +type StatusResponse struct { + Cloud CloudStatus `json:"cloud"` +} + // GenerateResponse is the response passed into [GenerateResponseFunc]. type GenerateResponse struct { // Model is the model name that generated the response. diff --git a/app/server/server.go b/app/server/server.go index 2e0c2d1ed..74913828a 100644 --- a/app/server/server.go +++ b/app/server/server.go @@ -205,6 +205,11 @@ func (s *Server) cmd(ctx context.Context) (*exec.Cmd, error) { return nil, err } + cloudDisabled, err := s.store.CloudDisabled() + if err != nil { + return nil, err + } + cmd := commandContext(ctx, s.bin, "serve") cmd.Stdout, cmd.Stderr = s.log, s.log @@ -230,6 +235,11 @@ func (s *Server) cmd(ctx context.Context) (*exec.Cmd, error) { if settings.ContextLength > 0 { env["OLLAMA_CONTEXT_LENGTH"] = strconv.Itoa(settings.ContextLength) } + if cloudDisabled { + env["OLLAMA_NO_CLOUD"] = "1" + } else { + env["OLLAMA_NO_CLOUD"] = "0" + } cmd.Env = []string{} for k, v := range env { cmd.Env = append(cmd.Env, k+"="+v) diff --git a/app/server/server_test.go b/app/server/server_test.go index f533073d3..8d3a6f27e 100644 --- a/app/server/server_test.go +++ b/app/server/server_test.go @@ -111,7 +111,7 @@ func TestServerCmd(t *testing.T) { for _, want := range tt.want { found := false for _, env := range cmd.Env { - if strings.Contains(env, want) { + if strings.HasPrefix(env, want) { found = true break } @@ -123,7 +123,7 @@ func TestServerCmd(t *testing.T) { for _, dont := range tt.dont { for _, env := range cmd.Env { - if strings.Contains(env, dont) { + if strings.HasPrefix(env, dont) { t.Errorf("unexpected environment variable: %s", env) } } @@ -136,6 +136,75 @@ func TestServerCmd(t *testing.T) { } } +func TestServerCmdCloudSettingEnv(t *testing.T) { + tests := []struct { + name string + envValue string + configContent string + want string + }{ + { + name: "default cloud enabled", + want: "OLLAMA_NO_CLOUD=0", + }, + { + name: "env disables cloud", + envValue: "1", + want: "OLLAMA_NO_CLOUD=1", + }, + { + name: "config disables cloud", + configContent: `{"disable_ollama_cloud": true}`, + want: "OLLAMA_NO_CLOUD=1", + }, + { + name: "invalid env disables cloud", + envValue: "invalid", + want: "OLLAMA_NO_CLOUD=1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("OLLAMA_NO_CLOUD", tt.envValue) + + if tt.configContent != "" { + configDir := filepath.Join(tmpHome, ".ollama") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "server.json") + if err := os.WriteFile(configPath, []byte(tt.configContent), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + } + + st := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")} + defer st.Close() + + s := &Server{store: st} + cmd, err := s.cmd(t.Context()) + if err != nil { + t.Fatalf("s.cmd() error = %v", err) + } + + found := false + for _, env := range cmd.Env { + if env == tt.want { + found = true + break + } + } + if !found { + t.Fatalf("expected environment variable %q in command env", tt.want) + } + }) + } +} + func TestGetInferenceComputer(t *testing.T) { tests := []struct { name string diff --git a/app/store/cloud_config.go b/app/store/cloud_config.go new file mode 100644 index 000000000..7a71cbd7b --- /dev/null +++ b/app/store/cloud_config.go @@ -0,0 +1,128 @@ +//go:build windows || darwin + +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/ollama/ollama/envconfig" +) + +const serverConfigFilename = "server.json" + +type serverConfig struct { + DisableOllamaCloud bool `json:"disable_ollama_cloud,omitempty"` +} + +// CloudDisabled returns whether cloud features should be disabled. +// The source of truth is: OLLAMA_NO_CLOUD OR ~/.ollama/server.json:disable_ollama_cloud. +func (s *Store) CloudDisabled() (bool, error) { + disabled, _, err := s.CloudStatus() + return disabled, err +} + +// CloudStatus returns whether cloud is disabled and the source of that decision. +// Source is one of: "none", "env", "config", "both". +func (s *Store) CloudStatus() (bool, string, error) { + if err := s.ensureDB(); err != nil { + return false, "", err + } + + configDisabled, err := readServerConfigCloudDisabled() + if err != nil { + return false, "", err + } + + envDisabled := envconfig.NoCloudEnv() + return envDisabled || configDisabled, cloudStatusSource(envDisabled, configDisabled), nil +} + +// SetCloudEnabled writes the cloud setting to ~/.ollama/server.json. +func (s *Store) SetCloudEnabled(enabled bool) error { + if err := s.ensureDB(); err != nil { + return err + } + return setCloudEnabled(enabled) +} + +func setCloudEnabled(enabled bool) error { + configPath, err := serverConfigPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return fmt.Errorf("create server config directory: %w", err) + } + + configMap := map[string]any{} + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &configMap); err != nil { + // If the existing file is invalid JSON, overwrite with a fresh object. + configMap = map[string]any{} + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read server config: %w", err) + } + + configMap["disable_ollama_cloud"] = !enabled + + data, err := json.MarshalIndent(configMap, "", " ") + if err != nil { + return fmt.Errorf("marshal server config: %w", err) + } + data = append(data, '\n') + + if err := os.WriteFile(configPath, data, 0o644); err != nil { + return fmt.Errorf("write server config: %w", err) + } + + return nil +} + +func readServerConfigCloudDisabled() (bool, error) { + configPath, err := serverConfigPath() + if err != nil { + return false, err + } + + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("read server config: %w", err) + } + + var cfg serverConfig + // Invalid or unexpected JSON should not block startup; treat as default. + if json.Unmarshal(data, &cfg) == nil { + return cfg.DisableOllamaCloud, nil + } + return false, nil +} + +func serverConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".ollama", serverConfigFilename), nil +} + +func cloudStatusSource(envDisabled bool, configDisabled bool) string { + switch { + case envDisabled && configDisabled: + return "both" + case envDisabled: + return "env" + case configDisabled: + return "config" + default: + return "none" + } +} diff --git a/app/store/cloud_config_test.go b/app/store/cloud_config_test.go new file mode 100644 index 000000000..a8154d60c --- /dev/null +++ b/app/store/cloud_config_test.go @@ -0,0 +1,130 @@ +//go:build windows || darwin + +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCloudDisabled(t *testing.T) { + tests := []struct { + name string + envValue string + configContent string + wantDisabled bool + wantSource string + }{ + { + name: "default enabled", + wantDisabled: false, + wantSource: "none", + }, + { + name: "env disables cloud", + envValue: "1", + wantDisabled: true, + wantSource: "env", + }, + { + name: "config disables cloud", + configContent: `{"disable_ollama_cloud": true}`, + wantDisabled: true, + wantSource: "config", + }, + { + name: "env and config", + envValue: "1", + configContent: `{"disable_ollama_cloud": false}`, + wantDisabled: true, + wantSource: "env", + }, + { + name: "invalid config is ignored", + configContent: `{bad`, + wantDisabled: false, + wantSource: "none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpHome := t.TempDir() + setTestHome(t, tmpHome) + t.Setenv("OLLAMA_NO_CLOUD", tt.envValue) + + if tt.configContent != "" { + configDir := filepath.Join(tmpHome, ".ollama") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, serverConfigFilename) + if err := os.WriteFile(configPath, []byte(tt.configContent), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + } + + s := &Store{DBPath: filepath.Join(tmpHome, "db.sqlite")} + defer s.Close() + + disabled, err := s.CloudDisabled() + if err != nil { + t.Fatalf("CloudDisabled() error = %v", err) + } + if disabled != tt.wantDisabled { + t.Fatalf("CloudDisabled() = %v, want %v", disabled, tt.wantDisabled) + } + + statusDisabled, source, err := s.CloudStatus() + if err != nil { + t.Fatalf("CloudStatus() error = %v", err) + } + if statusDisabled != tt.wantDisabled { + t.Fatalf("CloudStatus() disabled = %v, want %v", statusDisabled, tt.wantDisabled) + } + if source != tt.wantSource { + t.Fatalf("CloudStatus() source = %v, want %v", source, tt.wantSource) + } + }) + } +} + +func TestSetCloudEnabled(t *testing.T) { + tmpHome := t.TempDir() + setTestHome(t, tmpHome) + + configDir := filepath.Join(tmpHome, ".ollama") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, serverConfigFilename) + if err := os.WriteFile(configPath, []byte(`{"another_key":"value","disable_ollama_cloud":true}`), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + + s := &Store{DBPath: filepath.Join(tmpHome, "db.sqlite")} + defer s.Close() + + if err := s.SetCloudEnabled(true); err != nil { + t.Fatalf("SetCloudEnabled(true) error = %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + if got["disable_ollama_cloud"] != false { + t.Fatalf("disable_ollama_cloud = %v, want false", got["disable_ollama_cloud"]) + } + if got["another_key"] != "value" { + t.Fatalf("another_key = %v, want value", got["another_key"]) + } +} diff --git a/app/store/database.go b/app/store/database.go index 0f268c6fa..8e97b9c8c 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -14,7 +14,7 @@ import ( // currentSchemaVersion defines the current database schema version. // Increment this when making schema changes that require migrations. -const currentSchemaVersion = 12 +const currentSchemaVersion = 13 // database wraps the SQLite connection. // SQLite handles its own locking for concurrent access: @@ -84,6 +84,7 @@ func (db *database) init() error { sidebar_open BOOLEAN NOT NULL DEFAULT 0, think_enabled BOOLEAN NOT NULL DEFAULT 0, think_level TEXT NOT NULL DEFAULT '', + cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0, remote TEXT NOT NULL DEFAULT '', -- deprecated schema_version INTEGER NOT NULL DEFAULT %d ); @@ -244,6 +245,12 @@ func (db *database) migrate() error { return fmt.Errorf("migrate v11 to v12: %w", err) } version = 12 + case 12: + // add cloud_setting_migrated column to settings table + if err := db.migrateV12ToV13(); err != nil { + return fmt.Errorf("migrate v12 to v13: %w", err) + } + version = 13 default: // If we have a version we don't recognize, just set it to current // This might happen during development @@ -452,6 +459,21 @@ func (db *database) migrateV11ToV12() error { return nil } +// migrateV12ToV13 adds cloud_setting_migrated to settings. +func (db *database) migrateV12ToV13() error { + _, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0`) + if err != nil && !duplicateColumnError(err) { + return fmt.Errorf("add cloud_setting_migrated column: %w", err) + } + + _, err = db.conn.Exec(`UPDATE settings SET schema_version = 13`) + if err != nil { + return fmt.Errorf("update schema version: %w", err) + } + + return nil +} + // cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug func (db *database) cleanupOrphanedData() error { _, err := db.conn.Exec(` @@ -1108,9 +1130,9 @@ func (db *database) getSettings() (Settings, error) { var s Settings err := db.conn.QueryRow(` - SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, airplane_mode, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level + SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level FROM settings - `).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.AirplaneMode, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel) + `).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel) if err != nil { return Settings{}, fmt.Errorf("get settings: %w", err) } @@ -1121,14 +1143,40 @@ func (db *database) getSettings() (Settings, error) { func (db *database) setSettings(s Settings) error { _, err := db.conn.Exec(` UPDATE settings - SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, airplane_mode = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ? - `, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.AirplaneMode, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel) + SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ? + `, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel) if err != nil { return fmt.Errorf("set settings: %w", err) } return nil } +func (db *database) isCloudSettingMigrated() (bool, error) { + var migrated bool + err := db.conn.QueryRow("SELECT cloud_setting_migrated FROM settings").Scan(&migrated) + if err != nil { + return false, fmt.Errorf("get cloud setting migration status: %w", err) + } + return migrated, nil +} + +func (db *database) setCloudSettingMigrated(migrated bool) error { + _, err := db.conn.Exec("UPDATE settings SET cloud_setting_migrated = ?", migrated) + if err != nil { + return fmt.Errorf("set cloud setting migration status: %w", err) + } + return nil +} + +func (db *database) getAirplaneMode() (bool, error) { + var airplaneMode bool + err := db.conn.QueryRow("SELECT airplane_mode FROM settings").Scan(&airplaneMode) + if err != nil { + return false, fmt.Errorf("get airplane_mode: %w", err) + } + return airplaneMode, nil +} + func (db *database) getWindowSize() (int, int, error) { var width, height int err := db.conn.QueryRow("SELECT window_width, window_height FROM settings").Scan(&width, &height) diff --git a/app/store/migration_test.go b/app/store/migration_test.go index 57b37b706..0b3c06112 100644 --- a/app/store/migration_test.go +++ b/app/store/migration_test.go @@ -127,6 +127,65 @@ func TestNoConfigToMigrate(t *testing.T) { } } +func TestCloudMigrationFromAirplaneMode(t *testing.T) { + tmpHome := t.TempDir() + setTestHome(t, tmpHome) + t.Setenv("OLLAMA_NO_CLOUD", "") + + dbPath := filepath.Join(tmpHome, "db.sqlite") + db, err := newDatabase(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + + if _, err := db.conn.Exec("UPDATE settings SET airplane_mode = 1, cloud_setting_migrated = 0"); err != nil { + db.Close() + t.Fatalf("failed to seed airplane migration state: %v", err) + } + db.Close() + + s := Store{DBPath: dbPath} + defer s.Close() + + // Trigger DB initialization + one-time cloud migration. + if _, err := s.ID(); err != nil { + t.Fatalf("failed to initialize store: %v", err) + } + + disabled, err := s.CloudDisabled() + if err != nil { + t.Fatalf("CloudDisabled() error: %v", err) + } + if !disabled { + t.Fatal("expected cloud to be disabled after migrating airplane_mode=true") + } + + configPath := filepath.Join(tmpHome, ".ollama", serverConfigFilename) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read migrated server config: %v", err) + } + + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse migrated server config: %v", err) + } + if cfg["disable_ollama_cloud"] != true { + t.Fatalf("disable_ollama_cloud = %v, want true", cfg["disable_ollama_cloud"]) + } + + var airplaneMode, migrated bool + if err := s.db.conn.QueryRow("SELECT airplane_mode, cloud_setting_migrated FROM settings").Scan(&airplaneMode, &migrated); err != nil { + t.Fatalf("failed to read migration flags from DB: %v", err) + } + if !airplaneMode { + t.Fatal("expected legacy airplane_mode value to remain unchanged") + } + if !migrated { + t.Fatal("expected cloud_setting_migrated to be true") + } +} + const ( v1Schema = ` CREATE TABLE IF NOT EXISTS settings ( diff --git a/app/store/store.go b/app/store/store.go index 052fcd617..171ead8e2 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -149,9 +149,6 @@ type Settings struct { // ContextLength specifies the context length for the ollama server (using OLLAMA_CONTEXT_LENGTH) ContextLength int - // AirplaneMode when true, turns off Ollama Turbo features and only uses local models - AirplaneMode bool - // TurboEnabled indicates if Ollama Turbo features are enabled TurboEnabled bool @@ -259,6 +256,40 @@ func (s *Store) ensureDB() error { } } + // Run one-time migration from legacy airplane_mode behavior. + if err := s.migrateCloudSetting(database); err != nil { + return fmt.Errorf("migrate cloud setting: %w", err) + } + + return nil +} + +// migrateCloudSetting migrates legacy airplane_mode into server.json exactly once. +// After this, cloud state is sourced from server.json OR OLLAMA_NO_CLOUD. +func (s *Store) migrateCloudSetting(database *database) error { + migrated, err := database.isCloudSettingMigrated() + if err != nil { + return err + } + if migrated { + return nil + } + + airplaneMode, err := database.getAirplaneMode() + if err != nil { + return err + } + + if airplaneMode { + if err := setCloudEnabled(false); err != nil { + return fmt.Errorf("migrate airplane_mode to cloud disabled: %w", err) + } + } + + if err := database.setCloudSettingMigrated(true); err != nil { + return err + } + return nil } diff --git a/app/store/test_home_test.go b/app/store/test_home_test.go new file mode 100644 index 000000000..df2f98851 --- /dev/null +++ b/app/store/test_home_test.go @@ -0,0 +1,11 @@ +//go:build windows || darwin + +package store + +import "testing" + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) +} diff --git a/app/tools/cloud_policy.go b/app/tools/cloud_policy.go new file mode 100644 index 000000000..3b05577ef --- /dev/null +++ b/app/tools/cloud_policy.go @@ -0,0 +1,35 @@ +//go:build windows || darwin + +package tools + +import ( + "context" + "errors" + + "github.com/ollama/ollama/api" + internalcloud "github.com/ollama/ollama/internal/cloud" +) + +// ensureCloudEnabledForTool checks cloud policy from the connected Ollama server. +// If policy cannot be determined, this fails closed and blocks the operation. +func ensureCloudEnabledForTool(ctx context.Context, operation string) error { + // Reuse shared message formatting; policy evaluation is still done via + // the connected server's /api/status endpoint below. + disabledMessage := internalcloud.DisabledError(operation) + + client, err := api.ClientFromEnvironment() + if err != nil { + return errors.New(disabledMessage + " (unable to verify server cloud policy)") + } + + status, err := client.CloudStatusExperimental(ctx) + if err != nil { + return errors.New(disabledMessage + " (unable to verify server cloud policy)") + } + + if status.Cloud.Disabled { + return errors.New(disabledMessage) + } + + return nil +} diff --git a/app/tools/cloud_policy_test.go b/app/tools/cloud_policy_test.go new file mode 100644 index 000000000..5bd83cae2 --- /dev/null +++ b/app/tools/cloud_policy_test.go @@ -0,0 +1,73 @@ +//go:build windows || darwin + +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestEnsureCloudEnabledForTool(t *testing.T) { + const op = "web search is unavailable" + const disabledPrefix = "ollama cloud is disabled: web search is unavailable" + + t.Run("enabled allows tool execution", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/status" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"cloud":{"disabled":false,"source":"none"}}`)) + })) + t.Cleanup(ts.Close) + t.Setenv("OLLAMA_HOST", ts.URL) + + if err := ensureCloudEnabledForTool(context.Background(), op); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + + t.Run("disabled blocks tool execution", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/status" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"cloud":{"disabled":true,"source":"config"}}`)) + })) + t.Cleanup(ts.Close) + t.Setenv("OLLAMA_HOST", ts.URL) + + err := ensureCloudEnabledForTool(context.Background(), op) + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); got != disabledPrefix { + t.Fatalf("unexpected error: %q", got) + } + }) + + t.Run("status unavailable fails closed", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(ts.Close) + t.Setenv("OLLAMA_HOST", ts.URL) + + err := ensureCloudEnabledForTool(context.Background(), op) + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !strings.Contains(got, disabledPrefix) { + t.Fatalf("expected disabled prefix, got %q", got) + } + if got := err.Error(); !strings.Contains(got, "unable to verify server cloud policy") { + t.Fatalf("expected verification failure detail, got %q", got) + } + }) +} diff --git a/app/tools/web_fetch.go b/app/tools/web_fetch.go index 67a582d35..15e27780d 100644 --- a/app/tools/web_fetch.go +++ b/app/tools/web_fetch.go @@ -77,6 +77,10 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin } func performWebFetch(ctx context.Context, targetURL string) (*FetchResponse, error) { + if err := ensureCloudEnabledForTool(ctx, "web fetch is unavailable"); err != nil { + return nil, err + } + reqBody := FetchRequest{URL: targetURL} jsonBody, err := json.Marshal(reqBody) if err != nil { diff --git a/app/tools/web_search.go b/app/tools/web_search.go index 1cb12ab76..fd37835f5 100644 --- a/app/tools/web_search.go +++ b/app/tools/web_search.go @@ -93,6 +93,10 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri } func performWebSearch(ctx context.Context, query string, maxResults int) (*SearchResponse, error) { + if err := ensureCloudEnabledForTool(ctx, "web search is unavailable"); err != nil { + return nil, err + } + reqBody := SearchRequest{Query: query, MaxResults: maxResults} jsonBody, err := json.Marshal(reqBody) diff --git a/app/ui/app/codegen/gotypes.gen.ts b/app/ui/app/codegen/gotypes.gen.ts index 0bf86f2b4..0f4594209 100644 --- a/app/ui/app/codegen/gotypes.gen.ts +++ b/app/ui/app/codegen/gotypes.gen.ts @@ -406,7 +406,6 @@ export class Settings { Tools: boolean; WorkingDir: string; ContextLength: number; - AirplaneMode: boolean; TurboEnabled: boolean; WebSearchEnabled: boolean; ThinkEnabled: boolean; @@ -424,7 +423,6 @@ export class Settings { this.Tools = source["Tools"]; this.WorkingDir = source["WorkingDir"]; this.ContextLength = source["ContextLength"]; - this.AirplaneMode = source["AirplaneMode"]; this.TurboEnabled = source["TurboEnabled"]; this.WebSearchEnabled = source["WebSearchEnabled"]; this.ThinkEnabled = source["ThinkEnabled"]; diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index 273850d6b..739dfb09d 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -27,6 +27,12 @@ declare module "@/gotypes" { Model.prototype.isCloud = function (): boolean { return this.model.endsWith("cloud"); }; + +export type CloudStatusSource = "env" | "config" | "both" | "none"; +export interface CloudStatusResponse { + disabled: boolean; + source: CloudStatusSource; +} // Helper function to convert Uint8Array to base64 function uint8ArrayToBase64(uint8Array: Uint8Array): string { const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow @@ -285,6 +291,28 @@ export async function updateSettings(settings: Settings): Promise<{ }; } +export async function updateCloudSetting( + enabled: boolean, +): Promise { + const response = await fetch(`${API_BASE}/api/v1/cloud`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(error || "Failed to update cloud setting"); + } + + const data = await response.json(); + return { + disabled: Boolean(data.disabled), + source: (data.source as CloudStatusSource) || "none", + }; +} + export async function renameChat(chatId: string, title: string): Promise { const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}/rename`, { method: "PUT", @@ -414,3 +442,16 @@ export async function fetchHealth(): Promise { return false; } } + +export async function getCloudStatus(): Promise { + const response = await fetch(`${API_BASE}/api/v1/cloud`); + if (!response.ok) { + throw new Error(`Failed to fetch cloud status: ${response.status}`); + } + + const data = await response.json(); + return { + disabled: Boolean(data.disabled), + source: (data.source as CloudStatusSource) || "none", + }; +} diff --git a/app/ui/app/src/components/ChatForm.tsx b/app/ui/app/src/components/ChatForm.tsx index b13ebd802..9228f9e32 100644 --- a/app/ui/app/src/components/ChatForm.tsx +++ b/app/ui/app/src/components/ChatForm.tsx @@ -22,6 +22,7 @@ import { useUser } from "@/hooks/useUser"; import { DisplayLogin } from "@/components/DisplayLogin"; import { ErrorEvent, Message } from "@/gotypes"; import { useSettings } from "@/hooks/useSettings"; +import { useCloudStatus } from "@/hooks/useCloudStatus"; import { ThinkButton } from "./ThinkButton"; import { ErrorMessage } from "./ErrorMessage"; import { processFiles } from "@/utils/fileValidation"; @@ -141,12 +142,12 @@ function ChatForm({ const { settings: { webSearchEnabled, - airplaneMode, thinkEnabled, thinkLevel: settingsThinkLevel, }, setSettings, } = useSettings(); + const { cloudDisabled } = useCloudStatus(); // current supported models for web search const modelLower = selectedModel?.model.toLowerCase() || ""; @@ -180,6 +181,12 @@ function ChatForm({ setSettings, ]); + useEffect(() => { + if (cloudDisabled && webSearchEnabled) { + setSettings({ WebSearchEnabled: false }); + } + }, [cloudDisabled, webSearchEnabled, setSettings]); + const removeFile = (index: number) => { setMessage((prev) => ({ ...prev, @@ -234,19 +241,19 @@ function ChatForm({ // Determine if login banner should be shown const shouldShowLoginBanner = + !cloudDisabled && !isLoadingUser && !isAuthenticated && - ((webSearchEnabled && supportsWebSearch) || - (selectedModel?.isCloud() && !airplaneMode)); + ((webSearchEnabled && supportsWebSearch) || selectedModel?.isCloud()); // Determine which feature to highlight in the banner const getActiveFeatureForBanner = () => { + if (cloudDisabled) return null; if (!isAuthenticated) { if (loginPromptFeature) return loginPromptFeature; - if (webSearchEnabled && selectedModel?.isCloud() && !airplaneMode) - return "webSearch"; + if (webSearchEnabled && selectedModel?.isCloud()) return "webSearch"; if (webSearchEnabled) return "webSearch"; - if (selectedModel?.isCloud() && !airplaneMode) return "turbo"; + if (selectedModel?.isCloud()) return "turbo"; } return null; }; @@ -269,11 +276,12 @@ function ChatForm({ useEffect(() => { if ( isAuthenticated || - (!webSearchEnabled && !!selectedModel?.isCloud() && !airplaneMode) + cloudDisabled || + (!webSearchEnabled && !!selectedModel?.isCloud()) ) { setLoginPromptFeature(null); } - }, [isAuthenticated, webSearchEnabled, selectedModel, airplaneMode]); + }, [isAuthenticated, webSearchEnabled, selectedModel, cloudDisabled]); // When entering edit mode, populate the composition with existing data useEffect(() => { @@ -465,6 +473,10 @@ function ChatForm({ const handleSubmit = async () => { if (!message.content.trim() || isStreaming || isDownloading) return; + if (cloudDisabled && selectedModel?.isCloud()) { + return; + } + // Check if cloud mode is enabled but user is not authenticated if (shouldShowLoginBanner) { return; @@ -478,7 +490,8 @@ function ChatForm({ }), ); - const useWebSearch = supportsWebSearch && webSearchEnabled && !airplaneMode; + const useWebSearch = + supportsWebSearch && webSearchEnabled && !cloudDisabled; const useThink = modelSupportsThinkingLevels ? thinkLevel : supportsThinkToggling @@ -899,7 +912,7 @@ function ChatForm({ )} { if (!webSearchEnabled && !isAuthenticated) { @@ -940,6 +953,7 @@ function ChatForm({ !isDownloading && (!message.content.trim() || shouldShowLoginBanner || + (cloudDisabled && selectedModel?.isCloud()) || message.fileErrors.length > 0) } className={`flex items-center justify-center h-9 w-9 rounded-full disabled:cursor-default cursor-pointer bg-black text-white dark:bg-white dark:text-black disabled:opacity-10 focus:outline-none focus:ring-2 focus:ring-blue-500`} diff --git a/app/ui/app/src/components/ModelPicker.tsx b/app/ui/app/src/components/ModelPicker.tsx index 768e46b4f..6908c29bf 100644 --- a/app/ui/app/src/components/ModelPicker.tsx +++ b/app/ui/app/src/components/ModelPicker.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { Model } from "@/gotypes"; import { useSelectedModel } from "@/hooks/useSelectedModel"; -import { useSettings } from "@/hooks/useSettings"; +import { useCloudStatus } from "@/hooks/useCloudStatus"; import { useQueryClient } from "@tanstack/react-query"; import { getModelUpstreamInfo } from "@/api"; import { ArrowDownTrayIcon } from "@heroicons/react/24/outline"; @@ -34,7 +34,7 @@ export const ModelPicker = forwardRef< chatId, searchQuery, ); - const { settings } = useSettings(); + const { cloudDisabled } = useCloudStatus(); const dropdownRef = useRef(null); const searchInputRef = useRef(null); const queryClient = useQueryClient(); @@ -219,7 +219,7 @@ export const ModelPicker = forwardRef< models={models} selectedModel={selectedModel} onModelSelect={handleModelSelect} - airplaneMode={settings.airplaneMode} + cloudDisabled={cloudDisabled} isOpen={isOpen} /> @@ -233,13 +233,13 @@ export const ModelList = forwardRef(function ModelList( models, selectedModel, onModelSelect, - airplaneMode, + cloudDisabled, isOpen, }: { models: Model[]; selectedModel: Model | null; onModelSelect: (model: Model) => void; - airplaneMode: boolean; + cloudDisabled: boolean; isOpen: boolean; }, ref, @@ -348,7 +348,7 @@ export const ModelList = forwardRef(function ModelList( )} {model.digest === undefined && - (airplaneMode || !model.isCloud()) && ( + (cloudDisabled || !model.isCloud()) && ( (null); const [pollingInterval, setPollingInterval] = useState(null); const navigate = useNavigate(); + const { + cloudDisabled, + cloudStatus, + isLoading: cloudStatusLoading, + } = useCloudStatus(); const { data: settingsData, @@ -74,6 +86,50 @@ export default function Settings() { }, }); + const updateCloudMutation = useMutation({ + mutationFn: (enabled: boolean) => updateCloudSetting(enabled), + onMutate: async (enabled: boolean) => { + await queryClient.cancelQueries({ queryKey: ["cloudStatus"] }); + + const previous = queryClient.getQueryData([ + "cloudStatus", + ]); + const envForcesDisabled = + previous?.source === "env" || previous?.source === "both"; + + queryClient.setQueryData( + ["cloudStatus"], + previous + ? { + ...previous, + disabled: !enabled || envForcesDisabled, + } + : { + disabled: !enabled, + source: "config", + }, + ); + + return { previous }; + }, + onError: (_error, _enabled, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(["cloudStatus"], context.previous); + } + }, + onSuccess: (status) => { + queryClient.setQueryData( + ["cloudStatus"], + status, + ); + queryClient.invalidateQueries({ queryKey: ["models"] }); + queryClient.invalidateQueries({ queryKey: ["cloudStatus"] }); + + setShowSaved(true); + setTimeout(() => setShowSaved(false), 1500); + }, + }); + useEffect(() => { refetchUser(); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -149,12 +205,16 @@ export default function Settings() { Agent: false, Tools: false, ContextLength: 4096, - AirplaneMode: false, }); updateSettingsMutation.mutate(defaultSettings); } }; + const cloudOverriddenByEnv = + cloudStatus?.source === "env" || cloudStatus?.source === "both"; + const cloudToggleDisabled = + cloudStatusLoading || updateCloudMutation.isPending || cloudOverriddenByEnv; + const handleConnectOllamaAccount = async () => { setConnectionError(null); @@ -237,7 +297,7 @@ export default function Settings() {
{/* Connect Ollama Account */}
-
+
{isLoading ? ( // Loading skeleton, this will only happen if the app started recently @@ -344,6 +404,34 @@ export default function Settings() { {/* Local Configuration */}
+ +
+
+ +
+ + + {cloudOverriddenByEnv + ? "The OLLAMA_NO_CLOUD environment variable is currently forcing cloud off." + : "Enable cloud models and web search."} + +
+
+
+ { + if (cloudOverriddenByEnv) { + return; + } + updateCloudMutation.mutate(checked); + }} + /> +
+
+
+ {/* Expose Ollama */}
@@ -440,35 +528,6 @@ export default function Settings() {
- {/* Airplane Mode */} - -
-
- - - -
- - - Airplane mode keeps data local, disabling cloud models - and web search. - -
-
-
- - handleChange("AirplaneMode", checked) - } - /> -
-
-
diff --git a/app/ui/app/src/hooks/useChats.ts b/app/ui/app/src/hooks/useChats.ts index 410a80e7e..2493e5bee 100644 --- a/app/ui/app/src/hooks/useChats.ts +++ b/app/ui/app/src/hooks/useChats.ts @@ -6,8 +6,8 @@ import { useSelectedModel } from "./useSelectedModel"; import { createQueryBatcher } from "./useQueryBatcher"; import { useRefetchModels } from "./useModels"; import { useStreamingContext } from "@/contexts/StreamingContext"; -import { useSettings } from "./useSettings"; import { getModelCapabilities } from "@/api"; +import { useCloudStatus } from "./useCloudStatus"; export const useChats = () => { return useQuery({ @@ -116,11 +116,9 @@ export const useIsModelStale = (modelName: string) => { export const useShouldShowStaleDisplay = (model: Model | null) => { const isStale = useIsModelStale(model?.model || ""); const { data: dismissedModels } = useDismissedStaleModels(); - const { - settings: { airplaneMode }, - } = useSettings(); + const { cloudDisabled } = useCloudStatus(); - if (model?.isCloud() && !airplaneMode) { + if (model?.isCloud() && !cloudDisabled) { return false; } diff --git a/app/ui/app/src/hooks/useCloudStatus.ts b/app/ui/app/src/hooks/useCloudStatus.ts new file mode 100644 index 000000000..7cd4eb8e9 --- /dev/null +++ b/app/ui/app/src/hooks/useCloudStatus.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCloudStatus, type CloudStatusResponse } from "@/api"; + +export function useCloudStatus() { + const cloudQuery = useQuery({ + queryKey: ["cloudStatus"], + queryFn: getCloudStatus, + retry: false, + staleTime: 60 * 1000, + }); + + return { + cloudStatus: cloudQuery.data, + cloudDisabled: cloudQuery.data?.disabled ?? false, + isKnown: cloudQuery.data !== null && cloudQuery.data !== undefined, + isLoading: cloudQuery.isLoading, + isError: cloudQuery.isError, + error: cloudQuery.error, + }; +} diff --git a/app/ui/app/src/hooks/useModels.ts b/app/ui/app/src/hooks/useModels.ts index 10cfdfc91..032805697 100644 --- a/app/ui/app/src/hooks/useModels.ts +++ b/app/ui/app/src/hooks/useModels.ts @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { Model } from "@/gotypes"; import { getModels } from "@/api"; import { mergeModels } from "@/utils/mergeModels"; -import { useSettings } from "./useSettings"; import { useMemo } from "react"; +import { useCloudStatus } from "./useCloudStatus"; export function useModels(searchQuery = "") { - const { settings } = useSettings(); + const { cloudDisabled } = useCloudStatus(); const localQuery = useQuery({ queryKey: ["models", searchQuery], queryFn: () => getModels(searchQuery), @@ -20,7 +20,7 @@ export function useModels(searchQuery = "") { }); const allModels = useMemo(() => { - const models = mergeModels(localQuery.data || [], settings.airplaneMode); + const models = mergeModels(localQuery.data || [], cloudDisabled); if (searchQuery && searchQuery.trim()) { const query = searchQuery.toLowerCase().trim(); @@ -40,7 +40,7 @@ export function useModels(searchQuery = "") { } return models; - }, [localQuery.data, searchQuery, settings.airplaneMode]); + }, [localQuery.data, searchQuery, cloudDisabled]); return { ...localQuery, diff --git a/app/ui/app/src/hooks/useSelectedModel.ts b/app/ui/app/src/hooks/useSelectedModel.ts index 57a8c9201..b807b7db5 100644 --- a/app/ui/app/src/hooks/useSelectedModel.ts +++ b/app/ui/app/src/hooks/useSelectedModel.ts @@ -7,6 +7,7 @@ import { Model } from "@/gotypes"; import { FEATURED_MODELS } from "@/utils/mergeModels"; import { getTotalVRAM } from "@/utils/vram.ts"; import { getInferenceCompute } from "@/api"; +import { useCloudStatus } from "./useCloudStatus"; export function recommendDefaultModel(totalVRAM: number): string { const vram = Math.max(0, Number(totalVRAM) || 0); @@ -22,6 +23,7 @@ export function recommendDefaultModel(totalVRAM: number): string { export function useSelectedModel(currentChatId?: string, searchQuery?: string) { const { settings, setSettings } = useSettings(); const { data: models = [], isLoading } = useModels(searchQuery || ""); + const { cloudDisabled } = useCloudStatus(); const { data: chatData, isLoading: isChatLoading } = useChat( currentChatId && currentChatId !== "new" ? currentChatId : "", ); @@ -46,12 +48,11 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { const restoredChatRef = useRef(null); const selectedModel: Model | null = useMemo(() => { - // if airplane mode is on and selected model ends with cloud, - // switch to recommended default model - if (settings.airplaneMode && settings.selectedModel?.endsWith("cloud")) { + // If cloud is disabled and selected model ends with cloud, switch to a local default. + if (cloudDisabled && settings.selectedModel?.endsWith("cloud")) { return ( models.find((m) => m.model === recommendedModel) || - models.find((m) => m.isCloud) || + models.find((m) => !m.isCloud()) || models.find((m) => m.digest === undefined || m.digest === "") || models[0] || null @@ -68,7 +69,7 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { "qwen3-coder:480b", ]; const shouldMigrate = - !settings.airplaneMode && + !cloudDisabled && settings.turboEnabled && baseModelsToMigrate.includes(settings.selectedModel); @@ -96,13 +97,18 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { })) || null ); - }, [models, settings.selectedModel, settings.airplaneMode, recommendedModel]); + }, [ + models, + settings.selectedModel, + cloudDisabled, + recommendedModel, + ]); useEffect(() => { if (!selectedModel) return; if ( - settings.airplaneMode && + cloudDisabled && settings.selectedModel?.endsWith("cloud") && selectedModel.model !== settings.selectedModel ) { @@ -110,13 +116,17 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { } if ( - !settings.airplaneMode && + !cloudDisabled && settings.turboEnabled && selectedModel.model !== settings.selectedModel ) { setSettings({ SelectedModel: selectedModel.model, TurboEnabled: false }); } - }, [selectedModel, settings.airplaneMode, settings.selectedModel]); + }, [ + selectedModel, + cloudDisabled, + settings.selectedModel, + ]); // Set model from chat history when chat data loads useEffect(() => { @@ -169,7 +179,9 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { const defaultModel = models.find((m) => m.model === recommendedModel) || - models.find((m) => m.isCloud()) || + (cloudDisabled + ? models.find((m) => !m.isCloud()) + : models.find((m) => m.isCloud())) || models.find((m) => m.digest === undefined || m.digest === "") || models[0]; @@ -181,6 +193,7 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) { inferenceComputes.length, models.length, settings.selectedModel, + cloudDisabled, ]); // Add the selected model to the models list if it's not already there diff --git a/app/ui/app/src/hooks/useSettings.ts b/app/ui/app/src/hooks/useSettings.ts index 638be3ec7..266b18eb1 100644 --- a/app/ui/app/src/hooks/useSettings.ts +++ b/app/ui/app/src/hooks/useSettings.ts @@ -9,7 +9,6 @@ interface SettingsState { webSearchEnabled: boolean; selectedModel: string; sidebarOpen: boolean; - airplaneMode: boolean; thinkEnabled: boolean; thinkLevel: string; } @@ -51,7 +50,6 @@ export function useSettings() { thinkLevel: settingsData?.settings?.ThinkLevel ?? "none", selectedModel: settingsData?.settings?.SelectedModel ?? "", sidebarOpen: settingsData?.settings?.SidebarOpen ?? false, - airplaneMode: settingsData?.settings?.AirplaneMode ?? false, }), [settingsData?.settings], ); diff --git a/app/ui/app/src/routes/__root.tsx b/app/ui/app/src/routes/__root.tsx index 25da49912..f94d2a0cc 100644 --- a/app/ui/app/src/routes/__root.tsx +++ b/app/ui/app/src/routes/__root.tsx @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { getSettings } from "@/api"; import { useQuery } from "@tanstack/react-query"; +import { useCloudStatus } from "@/hooks/useCloudStatus"; function RootComponent() { // This hook ensures settings are fetched on app startup @@ -9,6 +10,8 @@ function RootComponent() { queryKey: ["settings"], queryFn: getSettings, }); + // Fetch cloud status on startup (best-effort) + useCloudStatus(); return (
diff --git a/app/ui/app/src/utils/mergeModels.test.ts b/app/ui/app/src/utils/mergeModels.test.ts index 90092182a..a962dc37c 100644 --- a/app/ui/app/src/utils/mergeModels.test.ts +++ b/app/ui/app/src/utils/mergeModels.test.ts @@ -41,14 +41,14 @@ describe("Model merging logic", () => { expect(merged.length).toBe(FEATURED_MODELS.length + 2); }); - it("should hide cloud models in airplane mode", () => { + it("should hide cloud models when cloud is disabled", () => { const localModels: Model[] = [ new Model({ model: "gpt-oss:120b-cloud" }), new Model({ model: "llama3:latest" }), new Model({ model: "mistral:latest" }), ]; - const merged = mergeModels(localModels, true); // airplane mode = true + const merged = mergeModels(localModels, true); // cloud disabled = true // No cloud models should be present const cloudModels = merged.filter((m) => m.isCloud()); diff --git a/app/ui/app/src/utils/mergeModels.ts b/app/ui/app/src/utils/mergeModels.ts index abbfe00b8..814d2af42 100644 --- a/app/ui/app/src/utils/mergeModels.ts +++ b/app/ui/app/src/utils/mergeModels.ts @@ -32,7 +32,7 @@ function alphabeticalSort(a: Model, b: Model): number { //Merges models, sorting cloud models first, then other models export function mergeModels( localModels: Model[], - airplaneMode: boolean = false, + hideCloudModels: boolean = false, ): Model[] { const allModels = (localModels || []).map((model) => model); @@ -95,7 +95,7 @@ export function mergeModels( remainingModels.sort(alphabeticalSort); - return airplaneMode + return hideCloudModels ? [...featuredModels, ...remainingModels] : [...cloudModels, ...featuredModels, ...remainingModels]; } diff --git a/app/ui/ui.go b/app/ui/ui.go index 0b32f917e..ed9acc060 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -284,12 +284,15 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream)) mux.Handle("GET /api/v1/settings", handle(s.getSettings)) mux.Handle("POST /api/v1/settings", handle(s.settings)) + mux.Handle("GET /api/v1/cloud", handle(s.getCloudSetting)) + mux.Handle("POST /api/v1/cloud", handle(s.cloudSetting)) // Ollama proxy endpoints ollamaProxy := s.ollamaProxy() mux.Handle("GET /api/tags", ollamaProxy) mux.Handle("POST /api/show", ollamaProxy) mux.Handle("GET /api/version", ollamaProxy) + mux.Handle("GET /api/status", ollamaProxy) mux.Handle("HEAD /api/version", ollamaProxy) mux.Handle("POST /api/me", ollamaProxy) mux.Handle("POST /api/signout", ollamaProxy) @@ -1460,6 +1463,40 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error { }) } +func (s *Server) cloudSetting(w http.ResponseWriter, r *http.Request) error { + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + + if err := s.Store.SetCloudEnabled(req.Enabled); err != nil { + return fmt.Errorf("failed to persist cloud setting: %w", err) + } + + s.Restart() + + return s.writeCloudStatus(w) +} + +func (s *Server) getCloudSetting(w http.ResponseWriter, r *http.Request) error { + return s.writeCloudStatus(w) +} + +func (s *Server) writeCloudStatus(w http.ResponseWriter) error { + disabled, source, err := s.Store.CloudStatus() + if err != nil { + return fmt.Errorf("failed to load cloud status: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(map[string]any{ + "disabled": disabled, + "source": source, + }) +} + func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error { ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() diff --git a/app/ui/ui_test.go b/app/ui/ui_test.go index 980797435..5a5236007 100644 --- a/app/ui/ui_test.go +++ b/app/ui/ui_test.go @@ -115,6 +115,107 @@ func TestHandlePostApiSettings(t *testing.T) { } } +func TestHandlePostApiCloudSetting(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("OLLAMA_NO_CLOUD", "") + + testStore := &store.Store{ + DBPath: filepath.Join(t.TempDir(), "db.sqlite"), + } + defer testStore.Close() + + restartCount := 0 + server := &Server{ + Store: testStore, + Restart: func() { + restartCount++ + }, + } + + for _, tc := range []struct { + name string + body string + wantEnabled bool + }{ + {name: "disable cloud", body: `{"enabled": false}`, wantEnabled: false}, + {name: "enable cloud", body: `{"enabled": true}`, wantEnabled: true}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/v1/cloud", bytes.NewBufferString(tc.body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + if err := server.cloudSetting(rr, req); err != nil { + t.Fatalf("cloudSetting() error = %v", err) + } + if rr.Code != http.StatusOK { + t.Fatalf("cloudSetting() status = %d, want %d", rr.Code, http.StatusOK) + } + + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("cloudSetting() invalid response JSON: %v", err) + } + if got["disabled"] != !tc.wantEnabled { + t.Fatalf("response disabled = %v, want %v", got["disabled"], !tc.wantEnabled) + } + + disabled, err := testStore.CloudDisabled() + if err != nil { + t.Fatalf("CloudDisabled() error = %v", err) + } + if gotEnabled := !disabled; gotEnabled != tc.wantEnabled { + t.Fatalf("cloud enabled = %v, want %v", gotEnabled, tc.wantEnabled) + } + }) + } + + if restartCount != 2 { + t.Fatalf("Restart called %d times, want 2", restartCount) + } +} + +func TestHandleGetApiCloudSetting(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("OLLAMA_NO_CLOUD", "") + + testStore := &store.Store{ + DBPath: filepath.Join(t.TempDir(), "db.sqlite"), + } + defer testStore.Close() + + if err := testStore.SetCloudEnabled(false); err != nil { + t.Fatalf("SetCloudEnabled(false) error = %v", err) + } + + server := &Server{ + Store: testStore, + Restart: func() {}, + } + + req := httptest.NewRequest("GET", "/api/v1/cloud", nil) + rr := httptest.NewRecorder() + if err := server.getCloudSetting(rr, req); err != nil { + t.Fatalf("getCloudSetting() error = %v", err) + } + if rr.Code != http.StatusOK { + t.Fatalf("getCloudSetting() status = %d, want %d", rr.Code, http.StatusOK) + } + + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("getCloudSetting() invalid response JSON: %v", err) + } + if got["disabled"] != true { + t.Fatalf("response disabled = %v, want true", got["disabled"]) + } + if got["source"] != "config" { + t.Fatalf("response source = %v, want config", got["source"]) + } +} + func TestAuthenticationMiddleware(t *testing.T) { tests := []struct { name string diff --git a/cmd/cmd.go b/cmd/cmd.go index 873fb911f..96a918157 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1949,7 +1949,7 @@ func runInteractiveTUI(cmd *cobra.Command) { launchIntegration := func(name string) bool { // If not configured or model no longer exists, prompt for model selection configuredModel := config.IntegrationModel(name) - if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) { + if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) || config.IsCloudModelDisabled(cmd.Context(), configuredModel) { err := config.ConfigureIntegrationWithSelectors(cmd.Context(), name, singleSelector, multiSelector) if errors.Is(err, config.ErrCancelled) { return false // Return to main menu @@ -1971,7 +1971,7 @@ func runInteractiveTUI(cmd *cobra.Command) { return case tui.SelectionRunModel: _ = config.SetLastSelection("run") - if modelName := config.LastModel(); modelName != "" { + if modelName := config.LastModel(); modelName != "" && !config.IsCloudModelDisabled(cmd.Context(), modelName) { runModel(modelName) } else { modelName, err := config.SelectModelWithSelector(cmd.Context(), singleSelector) @@ -1999,6 +1999,9 @@ func runInteractiveTUI(cmd *cobra.Command) { continue } } + if config.IsCloudModelDisabled(cmd.Context(), modelName) { + continue // Return to main menu + } runModel(modelName) case tui.SelectionIntegration: _ = config.SetLastSelection(result.Integration) @@ -2008,6 +2011,17 @@ func runInteractiveTUI(cmd *cobra.Command) { case tui.SelectionChangeIntegration: _ = config.SetLastSelection(result.Integration) if len(result.Models) > 0 { + // Filter out cloud-disabled models + var filtered []string + for _, m := range result.Models { + if !config.IsCloudModelDisabled(cmd.Context(), m) { + filtered = append(filtered, m) + } + } + if len(filtered) == 0 { + continue + } + result.Models = filtered // Multi-select from modal (Editor integrations) if err := config.SaveAndEditIntegration(result.Integration, result.Models); err != nil { fmt.Fprintf(os.Stderr, "Error configuring %s: %v\n", result.Integration, err) @@ -2017,8 +2031,11 @@ func runInteractiveTUI(cmd *cobra.Command) { fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", result.Integration, err) } } else if result.Model != "" { + if config.IsCloudModelDisabled(cmd.Context(), result.Model) { + continue + } // Single-select from modal - save and launch - if err := config.SaveIntegrationModel(result.Integration, result.Model); err != nil { + if err := config.SaveIntegration(result.Integration, []string{result.Model}); err != nil { fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) continue } @@ -2273,6 +2290,7 @@ func NewCLI() *cobra.Command { envVars["OLLAMA_MAX_QUEUE"], envVars["OLLAMA_MODELS"], envVars["OLLAMA_NUM_PARALLEL"], + envVars["OLLAMA_NO_CLOUD"], envVars["OLLAMA_NOPRUNE"], envVars["OLLAMA_ORIGINS"], envVars["OLLAMA_SCHED_SPREAD"], diff --git a/cmd/config/claude_test.go b/cmd/config/claude_test.go index 6d0f85bd0..e5ad16a20 100644 --- a/cmd/config/claude_test.go +++ b/cmd/config/claude_test.go @@ -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")) diff --git a/cmd/config/config.go b/cmd/config/config.go index 867b247ae..ce9374ce5 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -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") } diff --git a/cmd/config/config_cloud_test.go b/cmd/config/config_cloud_test.go index b1002a54c..23e7313d9 100644 --- a/cmd/config/config_cloud_test.go +++ b/cmd/config/config_cloud_test.go @@ -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) } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index a491a276f..fedde7af8 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -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) } diff --git a/cmd/config/droid.go b/cmd/config/droid.go index b2a0d9693..d1a9f54dc 100644 --- a/cmd/config/droid.go +++ b/cmd/config/droid.go @@ -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) } diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index f06659672..b2bbcab54 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -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 ' 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 ' 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() diff --git a/cmd/config/integrations_test.go b/cmd/config/integrations_test.go index 4ff07681a..a69641757 100644 --- a/cmd/config/integrations_test.go +++ b/cmd/config/integrations_test.go @@ -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") diff --git a/cmd/config/openclaw.go b/cmd/config/openclaw.go index 3cf025a56..c64c2630e 100644 --- a/cmd/config/openclaw.go +++ b/cmd/config/openclaw.go @@ -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 diff --git a/cmd/config/opencode.go b/cmd/config/opencode.go index 1ca4aa2cf..b4715beb9 100644 --- a/cmd/config/opencode.go +++ b/cmd/config/opencode.go @@ -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) } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 5e7e533d9..932af312c 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -131,7 +131,7 @@ type model struct { signInURL string signInModel string signInSpinner int - signInFromModal bool // true if sign-in was triggered from modal (not main menu) + signInFromModal bool // true if sign-in was triggered from modal (not main menu) width int // terminal width from WindowSizeMsg statusMsg string // temporary status message shown near help text @@ -209,7 +209,26 @@ func (m *model) openMultiModelModal(integration string) { } func isCloudModel(name string) bool { - return strings.HasSuffix(name, ":cloud") + return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud") +} + +func cloudStatusDisabled(client *api.Client) bool { + status, err := client.CloudStatusExperimental(context.Background()) + if err != nil { + return false + } + return status.Cloud.Disabled +} + +func cloudModelDisabled(name string) bool { + if !isCloudModel(name) { + return false + } + client, err := api.ClientFromEnvironment() + if err != nil { + return false + } + return cloudStatusDisabled(client) } // checkCloudSignIn checks if a cloud model needs sign-in. @@ -222,6 +241,9 @@ func (m *model) checkCloudSignIn(modelName string, fromModal bool) tea.Cmd { if err != nil { return nil } + if cloudStatusDisabled(client) { + return nil + } user, err := client.Whoami(context.Background()) if err == nil && user != nil && user.Name != "" { return nil @@ -272,7 +294,11 @@ func (m *model) loadAvailableModels() { if err != nil { return } + cloudDisabled := cloudStatusDisabled(client) for _, mdl := range models.Models { + if cloudDisabled && mdl.RemoteModel != "" { + continue + } m.availableModels[mdl.Name] = true } } @@ -496,6 +522,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if configuredModel != "" && isCloudModel(configuredModel) && cloudModelDisabled(configuredModel) { + if item.integration != "" && config.IsEditorIntegration(item.integration) { + m.openMultiModelModal(item.integration) + } else { + m.openModelModal(configuredModel) + } + return m, nil + } + m.selected = true m.quitting = true return m, tea.Quit diff --git a/docs/cloud.mdx b/docs/cloud.mdx index 4b2722e35..a1dacd204 100644 --- a/docs/cloud.mdx +++ b/docs/cloud.mdx @@ -226,3 +226,7 @@ curl https://ollama.com/api/chat \ + +## Local only + +Ollama can run in local-only mode by [disabling Ollama's cloud](./faq#how-do-i-disable-ollama-cloud) features. \ No newline at end of file diff --git a/docs/faq.mdx b/docs/faq.mdx index 9a4cb9989..47f06529d 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -160,6 +160,26 @@ docker run -d -e HTTPS_PROXY=https://my.proxy.example.com -p 11434:11434 ollama- Ollama runs locally. We don't see your prompts or data when you run locally. When using cloud-hosted models, we process your prompts and responses to provide the service but do not store or log that content and never train on it. We collect basic account info and limited usage metadata to provide the service that does not include prompt or response content. We don't sell your data. You can delete your account anytime. +## How do I disable Ollama's cloud features? + +Ollama can run in local only mode by disabling Ollama's cloud features. By turning off Ollama's cloud features, you will lose the ability to use Ollama's cloud models and web search. + +Set `disable_ollama_cloud` in `~/.ollama/server.json`: + +```json +{ + "disable_ollama_cloud": true +} +``` + +You can also set the environment variable: + +```shell +OLLAMA_NO_CLOUD=1 +``` + +Restart Ollama after changing configuration. Once disabled, Ollama's logs will show `Ollama cloud disabled: true`. + ## How can I expose Ollama on my network? Ollama binds 127.0.0.1 port 11434 by default. Change the bind address with the `OLLAMA_HOST` environment variable. diff --git a/envconfig/config.go b/envconfig/config.go index 96886237c..ec36d451b 100644 --- a/envconfig/config.go +++ b/envconfig/config.go @@ -1,6 +1,8 @@ package envconfig import ( + "encoding/json" + "errors" "fmt" "log/slog" "math" @@ -11,6 +13,7 @@ import ( "runtime" "strconv" "strings" + "sync" "time" ) @@ -206,6 +209,8 @@ var ( UseAuth = Bool("OLLAMA_AUTH") // Enable Vulkan backend EnableVulkan = Bool("OLLAMA_VULKAN") + // NoCloudEnv checks the OLLAMA_NO_CLOUD environment variable. + NoCloudEnv = Bool("OLLAMA_NO_CLOUD") ) func String(s string) func() string { @@ -285,6 +290,7 @@ func AsMap() map[string]EnvVar { "OLLAMA_MAX_LOADED_MODELS": {"OLLAMA_MAX_LOADED_MODELS", MaxRunners(), "Maximum number of loaded models per GPU"}, "OLLAMA_MAX_QUEUE": {"OLLAMA_MAX_QUEUE", MaxQueue(), "Maximum number of queued requests"}, "OLLAMA_MODELS": {"OLLAMA_MODELS", Models(), "The path to the models directory"}, + "OLLAMA_NO_CLOUD": {"OLLAMA_NO_CLOUD", NoCloud(), "Disable Ollama cloud features (remote inference and web search)"}, "OLLAMA_NOHISTORY": {"OLLAMA_NOHISTORY", NoHistory(), "Do not preserve readline history"}, "OLLAMA_NOPRUNE": {"OLLAMA_NOPRUNE", NoPrune(), "Do not prune model blobs on startup"}, "OLLAMA_NUM_PARALLEL": {"OLLAMA_NUM_PARALLEL", NumParallel(), "Maximum number of parallel requests"}, @@ -334,3 +340,91 @@ func Values() map[string]string { func Var(key string) string { return strings.Trim(strings.TrimSpace(os.Getenv(key)), "\"'") } + +// serverConfigData holds the parsed fields from ~/.ollama/server.json. +type serverConfigData struct { + DisableOllamaCloud bool `json:"disable_ollama_cloud,omitempty"` +} + +var ( + serverCfgMu sync.RWMutex + serverCfgLoaded bool + serverCfg serverConfigData +) + +func loadServerConfig() { + serverCfgMu.RLock() + if serverCfgLoaded { + serverCfgMu.RUnlock() + return + } + serverCfgMu.RUnlock() + + cfg := serverConfigData{} + home, err := os.UserHomeDir() + if err == nil { + path := filepath.Join(home, ".ollama", "server.json") + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + slog.Debug("envconfig: could not read server config", "error", err) + } + } else if err := json.Unmarshal(data, &cfg); err != nil { + slog.Debug("envconfig: could not parse server config", "error", err) + } + } + + serverCfgMu.Lock() + defer serverCfgMu.Unlock() + if serverCfgLoaded { + return + } + serverCfg = cfg + serverCfgLoaded = true +} + +func cachedServerConfig() serverConfigData { + serverCfgMu.RLock() + defer serverCfgMu.RUnlock() + return serverCfg +} + +// ReloadServerConfig refreshes the cached ~/.ollama/server.json settings. +func ReloadServerConfig() { + serverCfgMu.Lock() + serverCfgLoaded = false + serverCfg = serverConfigData{} + serverCfgMu.Unlock() + + loadServerConfig() +} + +// NoCloud returns true if Ollama cloud features are disabled, +// checking both the OLLAMA_NO_CLOUD environment variable and +// the disable_ollama_cloud field in ~/.ollama/server.json. +func NoCloud() bool { + if NoCloudEnv() { + return true + } + loadServerConfig() + return cachedServerConfig().DisableOllamaCloud +} + +// NoCloudSource returns the source of the cloud-disabled decision. +// Returns "none", "env", "config", or "both". +func NoCloudSource() string { + envDisabled := NoCloudEnv() + loadServerConfig() + configDisabled := cachedServerConfig().DisableOllamaCloud + + switch { + case envDisabled && configDisabled: + return "both" + case envDisabled: + return "env" + case configDisabled: + return "config" + default: + return "none" + } +} diff --git a/envconfig/config_test.go b/envconfig/config_test.go index 4e7ae9eba..9242e66f5 100644 --- a/envconfig/config_test.go +++ b/envconfig/config_test.go @@ -3,6 +3,8 @@ package envconfig import ( "log/slog" "math" + "os" + "path/filepath" "testing" "time" @@ -326,3 +328,81 @@ func TestLogLevel(t *testing.T) { }) } } + +func TestNoCloud(t *testing.T) { + tests := []struct { + name string + envValue string + configContent string + wantDisabled bool + wantSource string + }{ + { + name: "neither env nor config", + wantDisabled: false, + wantSource: "none", + }, + { + name: "env only", + envValue: "1", + wantDisabled: true, + wantSource: "env", + }, + { + name: "config only", + configContent: `{"disable_ollama_cloud": true}`, + wantDisabled: true, + wantSource: "config", + }, + { + name: "both env and config", + envValue: "1", + configContent: `{"disable_ollama_cloud": true}`, + wantDisabled: true, + wantSource: "both", + }, + { + name: "config false", + configContent: `{"disable_ollama_cloud": false}`, + wantDisabled: false, + wantSource: "none", + }, + { + name: "invalid config ignored", + configContent: `{invalid json`, + wantDisabled: false, + wantSource: "none", + }, + { + name: "no config file", + wantDisabled: false, + wantSource: "none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + if tt.configContent != "" { + configDir := filepath.Join(home, ".ollama") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "server.json"), []byte(tt.configContent), 0o644); err != nil { + t.Fatal(err) + } + } + + setTestHome(t, home) + t.Setenv("OLLAMA_NO_CLOUD", tt.envValue) + + if got := NoCloud(); got != tt.wantDisabled { + t.Errorf("NoCloud() = %v, want %v", got, tt.wantDisabled) + } + + if got := NoCloudSource(); got != tt.wantSource { + t.Errorf("NoCloudSource() = %q, want %q", got, tt.wantSource) + } + }) + } +} diff --git a/envconfig/test_home_test.go b/envconfig/test_home_test.go new file mode 100644 index 000000000..993f1c0aa --- /dev/null +++ b/envconfig/test_home_test.go @@ -0,0 +1,10 @@ +package envconfig + +import "testing" + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + ReloadServerConfig() +} diff --git a/internal/cloud/policy.go b/internal/cloud/policy.go new file mode 100644 index 000000000..c540bff67 --- /dev/null +++ b/internal/cloud/policy.go @@ -0,0 +1,25 @@ +package cloud + +import ( + "github.com/ollama/ollama/envconfig" +) + +const DisabledMessagePrefix = "ollama cloud is disabled" + +// Status returns whether cloud is disabled and the source of the decision. +// Source is one of: "none", "env", "config", "both". +func Status() (disabled bool, source string) { + return envconfig.NoCloud(), envconfig.NoCloudSource() +} + +func Disabled() bool { + return envconfig.NoCloud() +} + +func DisabledError(operation string) string { + if operation == "" { + return DisabledMessagePrefix + } + + return DisabledMessagePrefix + ": " + operation +} diff --git a/internal/cloud/policy_test.go b/internal/cloud/policy_test.go new file mode 100644 index 000000000..28c36eba3 --- /dev/null +++ b/internal/cloud/policy_test.go @@ -0,0 +1,85 @@ +package cloud + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStatus(t *testing.T) { + tests := []struct { + name string + envValue string + configContent string + disabled bool + source string + }{ + { + name: "none", + disabled: false, + source: "none", + }, + { + name: "env only", + envValue: "1", + disabled: true, + source: "env", + }, + { + name: "config only", + configContent: `{"disable_ollama_cloud": true}`, + disabled: true, + source: "config", + }, + { + name: "both", + envValue: "1", + configContent: `{"disable_ollama_cloud": true}`, + disabled: true, + source: "both", + }, + { + name: "invalid config ignored", + configContent: `{invalid json`, + disabled: false, + source: "none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + if tt.configContent != "" { + configPath := filepath.Join(home, ".ollama", "server.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(tt.configContent), 0o644); err != nil { + t.Fatal(err) + } + } + + setTestHome(t, home) + t.Setenv("OLLAMA_NO_CLOUD", tt.envValue) + + disabled, source := Status() + if disabled != tt.disabled { + t.Fatalf("disabled: expected %v, got %v", tt.disabled, disabled) + } + if source != tt.source { + t.Fatalf("source: expected %q, got %q", tt.source, source) + } + }) + } +} + +func TestDisabledError(t *testing.T) { + if got := DisabledError(""); got != DisabledMessagePrefix { + t.Fatalf("expected %q, got %q", DisabledMessagePrefix, got) + } + + want := DisabledMessagePrefix + ": remote inference is unavailable" + if got := DisabledError("remote inference is unavailable"); got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/internal/cloud/test_home_test.go b/internal/cloud/test_home_test.go new file mode 100644 index 000000000..5da8c3a69 --- /dev/null +++ b/internal/cloud/test_home_test.go @@ -0,0 +1,14 @@ +package cloud + +import ( + "testing" + + "github.com/ollama/ollama/envconfig" +) + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + envconfig.ReloadServerConfig() +} diff --git a/server/aliases.go b/server/aliases.go index 727b3f2f6..18e9447e5 100644 --- a/server/aliases.go +++ b/server/aliases.go @@ -115,6 +115,15 @@ func (s *store) saveLocked() error { return err } + // Read existing file into a generic map to preserve unknown fields + // (e.g. disable_ollama_cloud) that aliasStore doesn't own. + existing := make(map[string]json.RawMessage) + if data, err := os.ReadFile(s.path); err == nil { + if err := json.Unmarshal(data, &existing); err != nil { + slog.Debug("failed to parse existing server config; preserving unknown fields skipped", "path", s.path, "error", err) + } + } + // Combine exact and prefix entries entries := make([]aliasEntry, 0, len(s.entries)+len(s.prefixEntries)) for _, entry := range s.entries { @@ -126,10 +135,17 @@ func (s *store) saveLocked() error { return strings.Compare(entries[i].Alias, entries[j].Alias) < 0 }) - cfg := serverConfig{ - Version: serverConfigVersion, - Aliases: entries, + // Overwrite only the keys we own + versionJSON, err := json.Marshal(serverConfigVersion) + if err != nil { + return err } + aliasesJSON, err := json.Marshal(entries) + if err != nil { + return err + } + existing["version"] = versionJSON + existing["aliases"] = aliasesJSON f, err := os.CreateTemp(dir, "router-*.json") if err != nil { @@ -138,7 +154,7 @@ func (s *store) saveLocked() error { enc := json.NewEncoder(f) enc.SetIndent("", " ") - if err := enc.Encode(cfg); err != nil { + if err := enc.Encode(existing); err != nil { _ = f.Close() _ = os.Remove(f.Name()) return err diff --git a/server/routes.go b/server/routes.go index b6ec2cb57..26cec3544 100644 --- a/server/routes.go +++ b/server/routes.go @@ -38,6 +38,7 @@ import ( "github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/format" "github.com/ollama/ollama/fs/ggml" + internalcloud "github.com/ollama/ollama/internal/cloud" "github.com/ollama/ollama/llm" "github.com/ollama/ollama/logutil" "github.com/ollama/ollama/manifest" @@ -58,6 +59,11 @@ import ( const signinURLStr = "https://ollama.com/connect?name=%s&key=%s" +const ( + cloudErrRemoteInferenceUnavailable = "remote model is unavailable" + cloudErrRemoteModelDetailsUnavailable = "remote model details are unavailable" +) + func shouldUseHarmony(model *Model) bool { if slices.Contains([]string{"gptoss", "gpt-oss"}, model.Config.ModelFamily) { // heuristic to check whether the template expects to be parsed via harmony: @@ -229,6 +235,11 @@ func (s *Server) GenerateHandler(c *gin.Context) { } if m.Config.RemoteHost != "" && m.Config.RemoteModel != "" { + if disabled, _ := internalcloud.Status(); disabled { + c.JSON(http.StatusForbidden, gin.H{"error": internalcloud.DisabledError(cloudErrRemoteInferenceUnavailable)}) + return + } + origModel := req.Model remoteURL, err := url.Parse(m.Config.RemoteHost) @@ -1066,9 +1077,12 @@ func (s *Server) ShowHandler(c *gin.Context) { resp, err := GetModelInfo(req) if err != nil { + var statusErr api.StatusError switch { case os.IsNotExist(err): c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("model '%s' not found", req.Model)}) + case errors.As(err, &statusErr): + c.JSON(statusErr.StatusCode, gin.H{"error": statusErr.ErrorMessage}) case err.Error() == errtypes.InvalidModelNameErrMsg: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: @@ -1095,6 +1109,15 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) { return nil, err } + if m.Config.RemoteHost != "" { + if disabled, _ := internalcloud.Status(); disabled { + return nil, api.StatusError{ + StatusCode: http.StatusForbidden, + ErrorMessage: internalcloud.DisabledError(cloudErrRemoteModelDetailsUnavailable), + } + } + } + modelDetails := api.ModelDetails{ ParentModel: m.ParentModel, Format: m.Config.ModelFormat, @@ -1571,6 +1594,7 @@ func (s *Server) GenerateRoutes(rc *ollama.Registry) (http.Handler, error) { r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "Ollama is running") }) r.HEAD("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) }) r.GET("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) }) + r.GET("/api/status", s.StatusHandler) // Local model cache management (new implementation is at end of function) r.POST("/api/pull", s.PullHandler) @@ -1634,6 +1658,8 @@ func (s *Server) GenerateRoutes(rc *ollama.Registry) (http.Handler, error) { func Serve(ln net.Listener) error { slog.SetDefault(logutil.NewLogger(os.Stderr, envconfig.LogLevel())) slog.Info("server config", "env", envconfig.Values()) + cloudDisabled, _ := internalcloud.Status() + slog.Info(fmt.Sprintf("Ollama cloud disabled: %t", cloudDisabled)) blobsDir, err := manifest.BlobsPath("") if err != nil { @@ -1824,6 +1850,16 @@ func streamResponse(c *gin.Context, ch chan any) { }) } +func (s *Server) StatusHandler(c *gin.Context) { + disabled, source := internalcloud.Status() + c.JSON(http.StatusOK, api.StatusResponse{ + Cloud: api.CloudStatus{ + Disabled: disabled, + Source: source, + }, + }) +} + func (s *Server) WhoamiHandler(c *gin.Context) { // todo allow other hosts u, err := url.Parse("https://ollama.com") @@ -2010,6 +2046,11 @@ func (s *Server) ChatHandler(c *gin.Context) { } if m.Config.RemoteHost != "" && m.Config.RemoteModel != "" { + if disabled, _ := internalcloud.Status(); disabled { + c.JSON(http.StatusForbidden, gin.H{"error": internalcloud.DisabledError(cloudErrRemoteInferenceUnavailable)}) + return + } + origModel := req.Model remoteURL, err := url.Parse(m.Config.RemoteHost) diff --git a/server/routes_aliases_test.go b/server/routes_aliases_test.go index e31529996..27d06229f 100644 --- a/server/routes_aliases_test.go +++ b/server/routes_aliases_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "path/filepath" "testing" @@ -16,7 +17,7 @@ import ( func TestAliasShadowingRejected(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) s := Server{} w := createRequest(t, s.CreateHandler, api.CreateRequest{ @@ -40,7 +41,7 @@ func TestAliasShadowingRejected(t *testing.T) { func TestAliasResolvesForChatRemote(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) var remoteModel string rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -256,7 +257,7 @@ func TestPrefixAliasChain(t *testing.T) { func TestPrefixAliasCRUD(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) s := Server{} @@ -364,7 +365,7 @@ func TestPrefixAliasCaseInsensitive(t *testing.T) { func TestPrefixAliasLocalModelPrecedence(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("HOME", t.TempDir()) + setTestHome(t, t.TempDir()) s := Server{} @@ -424,3 +425,51 @@ func TestPrefixAliasLocalModelPrecedence(t *testing.T) { t.Fatalf("expected resolved name to be %q, got %q", expectedTarget.DisplayShortest(), resolved.DisplayShortest()) } } + +func TestAliasSavePreservesCloudDisable(t *testing.T) { + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + configPath := filepath.Join(tmpDir, ".ollama", "server.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + + initial := map[string]any{ + "version": serverConfigVersion, + "disable_ollama_cloud": true, + "aliases": []aliasEntry{}, + } + data, err := json.Marshal(initial) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatal(err) + } + + s := Server{} + w := createRequest(t, s.CreateAliasHandler, aliasEntry{Alias: "alias-model", Target: "target-model"}) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + updated, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var updatedCfg map[string]json.RawMessage + if err := json.Unmarshal(updated, &updatedCfg); err != nil { + t.Fatal(err) + } + + raw, ok := updatedCfg["disable_ollama_cloud"] + if !ok { + t.Fatal("expected disable_ollama_cloud key to be preserved") + } + if string(raw) != "true" { + t.Fatalf("expected disable_ollama_cloud to remain true, got %s", string(raw)) + } +} diff --git a/server/routes_cloud_test.go b/server/routes_cloud_test.go new file mode 100644 index 000000000..b0ee126ea --- /dev/null +++ b/server/routes_cloud_test.go @@ -0,0 +1,94 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/ollama/ollama/api" + internalcloud "github.com/ollama/ollama/internal/cloud" +) + +func TestStatusHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestHome(t, t.TempDir()) + t.Setenv("OLLAMA_NO_CLOUD", "1") + + s := Server{} + w := createRequest(t, s.StatusHandler, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp api.StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if !resp.Cloud.Disabled { + t.Fatalf("expected cloud.disabled true, got false") + } + if resp.Cloud.Source != "env" { + t.Fatalf("expected cloud.source env, got %q", resp.Cloud.Source) + } +} + +func TestCloudDisabledBlocksRemoteOperations(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestHome(t, t.TempDir()) + t.Setenv("OLLAMA_NO_CLOUD", "1") + + s := Server{} + + w := createRequest(t, s.CreateHandler, api.CreateRequest{ + Model: "test-cloud", + RemoteHost: "example.com", + From: "test", + Info: map[string]any{ + "capabilities": []string{"completion"}, + }, + Stream: &stream, + }) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + t.Run("chat remote blocked", func(t *testing.T) { + w := createRequest(t, s.ChatHandler, api.ChatRequest{ + Model: "test-cloud", + Messages: []api.Message{{Role: "user", Content: "hi"}}, + }) + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + if got := w.Body.String(); got != `{"error":"`+internalcloud.DisabledError(cloudErrRemoteInferenceUnavailable)+`"}` { + t.Fatalf("unexpected response: %s", got) + } + }) + + t.Run("generate remote blocked", func(t *testing.T) { + w := createRequest(t, s.GenerateHandler, api.GenerateRequest{ + Model: "test-cloud", + Prompt: "hi", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + if got := w.Body.String(); got != `{"error":"`+internalcloud.DisabledError(cloudErrRemoteInferenceUnavailable)+`"}` { + t.Fatalf("unexpected response: %s", got) + } + }) + + t.Run("show remote blocked", func(t *testing.T) { + w := createRequest(t, s.ShowHandler, api.ShowRequest{ + Model: "test-cloud", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + if got := w.Body.String(); got != `{"error":"`+internalcloud.DisabledError(cloudErrRemoteModelDetailsUnavailable)+`"}` { + t.Fatalf("unexpected response: %s", got) + } + }) +} diff --git a/server/test_home_test.go b/server/test_home_test.go new file mode 100644 index 000000000..7a0393684 --- /dev/null +++ b/server/test_home_test.go @@ -0,0 +1,14 @@ +package server + +import ( + "testing" + + "github.com/ollama/ollama/envconfig" +) + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + envconfig.ReloadServerConfig() +} diff --git a/x/cmd/run.go b/x/cmd/run.go index 1bd452cd8..e5d7ea25e 100644 --- a/x/cmd/run.go +++ b/x/cmd/run.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "os/signal" @@ -18,6 +19,7 @@ import ( "golang.org/x/term" "github.com/ollama/ollama/api" + internalcloud "github.com/ollama/ollama/internal/cloud" "github.com/ollama/ollama/progress" "github.com/ollama/ollama/readline" "github.com/ollama/ollama/types/model" @@ -62,6 +64,18 @@ func isLocalServer() bool { return hostname == "localhost" || hostname == "127.0.0.1" || strings.Contains(parsed.Host, ":11434") } +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 +} + // truncateToolOutput truncates tool output to prevent context overflow. // Uses a smaller limit (4k tokens) for local models, larger (10k) for cloud/remote. func truncateToolOutput(output, modelName string) string { @@ -86,6 +100,10 @@ func waitForOllamaSignin(ctx context.Context) error { return err } + if disabled, known := cloudStatusDisabled(ctx, client); known && disabled { + return errors.New(internalcloud.DisabledError("cloud account endpoints are unavailable")) + } + // Get signin URL from initial Whoami call _, err = client.Whoami(ctx) if err != nil { @@ -664,6 +682,15 @@ func GenerateInteractive(cmd *cobra.Command, modelName string, wordWrap bool, op supportsTools = false } + if enableWebsearch { + if client, err := api.ClientFromEnvironment(); err == nil { + if disabled, known := cloudStatusDisabled(cmd.Context(), client); known && disabled { + fmt.Fprintf(os.Stderr, "%s\n", internalcloud.DisabledError("web search is unavailable")) + enableWebsearch = false + } + } + } + // Create tool registry only if model supports tools var toolRegistry *tools.Registry if supportsTools { diff --git a/x/tools/webfetch.go b/x/tools/webfetch.go index 82a006233..793e184c2 100644 --- a/x/tools/webfetch.go +++ b/x/tools/webfetch.go @@ -15,6 +15,7 @@ import ( "github.com/ollama/ollama/api" "github.com/ollama/ollama/auth" + internalcloud "github.com/ollama/ollama/internal/cloud" ) const ( @@ -71,6 +72,10 @@ type webFetchResponse struct { // Execute fetches content from a web page. // Uses Ollama key signing for authentication - this makes requests via ollama.com API. func (w *WebFetchTool) Execute(args map[string]any) (string, error) { + if internalcloud.Disabled() { + return "", errors.New(internalcloud.DisabledError("web fetch is unavailable")) + } + urlStr, ok := args["url"].(string) if !ok || urlStr == "" { return "", fmt.Errorf("url parameter is required") diff --git a/x/tools/websearch.go b/x/tools/websearch.go index 16b0dde2c..1da124af8 100644 --- a/x/tools/websearch.go +++ b/x/tools/websearch.go @@ -15,6 +15,7 @@ import ( "github.com/ollama/ollama/api" "github.com/ollama/ollama/auth" + internalcloud "github.com/ollama/ollama/internal/cloud" ) const ( @@ -77,6 +78,10 @@ type webSearchResult struct { // Execute performs the web search. // Uses Ollama key signing for authentication - this makes requests via ollama.com API. func (w *WebSearchTool) Execute(args map[string]any) (string, error) { + if internalcloud.Disabled() { + return "", errors.New(internalcloud.DisabledError("web search is unavailable")) + } + query, ok := args["query"].(string) if !ok || query == "" { return "", fmt.Errorf("query parameter is required")