Compare commits

...

3 Commits

Author SHA1 Message Date
Parth Sareen
160660e572 launch: use bundled OpenClaw ollama web search (#15757) 2026-04-22 16:34:19 -07:00
madflow
3b43b9bc4b docs: update structured outputs doc for cloud (#15733)
---------

Co-authored-by: Parth Sareen <parth.sareen@ollama.com>
2026-04-22 00:42:39 -07:00
Parth Sareen
21883571b7 launch: replace kimi-k2.5 with k2.6 as top recommended model (#15737) 2026-04-21 15:13:20 -07:00
6 changed files with 199 additions and 297 deletions

View File

@@ -301,7 +301,7 @@ func TestParseArgs(t *testing.T) {
func TestIsCloudModel(t *testing.T) { func TestIsCloudModel(t *testing.T) {
// isCloudModel now only uses Show API, so nil client always returns false // isCloudModel now only uses Show API, so nil client always returns false
t.Run("nil client returns false", func(t *testing.T) { t.Run("nil client returns false", func(t *testing.T) {
models := []string{"glm-5.1:cloud", "kimi-k2.5:cloud", "local-model"} models := []string{"glm-5.1:cloud", "kimi-k2.6:cloud", "local-model"}
for _, model := range models { for _, model := range models {
if isCloudModel(context.Background(), nil, model) { if isCloudModel(context.Background(), nil, model) {
t.Errorf("isCloudModel(%q) with nil client should return false", model) t.Errorf("isCloudModel(%q) with nil client should return false", model)
@@ -321,7 +321,7 @@ func names(items []ModelItem) []string {
func TestBuildModelList_NoExistingModels(t *testing.T) { func TestBuildModelList_NoExistingModels(t *testing.T) {
items, _, _, _ := buildModelList(nil, nil, "") items, _, _, _ := buildModelList(nil, nil, "")
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"} want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
if diff := cmp.Diff(want, names(items)); diff != "" { if diff := cmp.Diff(want, names(items)); diff != "" {
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff) t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
} }
@@ -350,7 +350,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsStillFirst(t *testing.T) {
// Cloud recs always come first among recommended, regardless of installed inventory. // Cloud recs always come first among recommended, regardless of installed inventory.
// Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems. // Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems.
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"} want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"}
if diff := cmp.Diff(want, got); diff != "" { if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff) t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff)
} }
@@ -366,7 +366,7 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
got := names(items) got := names(items)
// All recs pinned at top (cloud before local in mixed case), then non-recs // All recs pinned at top (cloud before local in mixed case), then non-recs
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"} want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
if diff := cmp.Diff(want, got); diff != "" { if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff) t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
} }
@@ -437,7 +437,7 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
if !strings.HasSuffix(item.Description, "(not downloaded)") { if !strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("non-installed recommended %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description) t.Errorf("non-installed recommended %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description)
} }
case "minimax-m2.7:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud": case "minimax-m2.7:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
if strings.HasSuffix(item.Description, "(not downloaded)") { if strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
} }
@@ -455,9 +455,9 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
got := names(items) got := names(items)
// gemma4 and glm-5.1:cloud are installed so they sort normally; // gemma4 and glm-5.1:cloud are installed so they sort normally;
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom // qwen3.5:cloud and qwen3.5 are not installed so they go to the bottom
// All recs: cloud first in mixed case, then local, in rec order within each // All recs: cloud first in mixed case, then local, in rec order within each
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"} want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
if diff := cmp.Diff(want, got); diff != "" { if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff) t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
} }
@@ -466,23 +466,23 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) { func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) {
existing := []modelInfo{ existing := []modelInfo{
{Name: "llama3.2:latest", Remote: false}, {Name: "llama3.2:latest", Remote: false},
{Name: "kimi-k2.5:cloud", Remote: true}, {Name: "kimi-k2.6:cloud", Remote: true},
} }
items, _, _, _ := buildModelList(existing, nil, "") items, _, _, _ := buildModelList(existing, nil, "")
got := names(items) got := names(items)
// kimi-k2.5:cloud is installed so it sorts normally; // kimi-k2.6:cloud is installed so it sorts normally;
// the rest of the recommendations are not installed so they go to the bottom // the rest of the recommendations are not installed so they go to the bottom
// All recs pinned at top (cloud first in mixed case), then non-recs // All recs pinned at top (cloud first in mixed case), then non-recs
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"} want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
if diff := cmp.Diff(want, got); diff != "" { if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff) t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
} }
for _, item := range items { for _, item := range items {
isCloud := strings.HasSuffix(item.Name, ":cloud") isCloud := strings.HasSuffix(item.Name, ":cloud")
isInstalled := slices.Contains([]string{"kimi-k2.5:cloud", "llama3.2"}, item.Name) isInstalled := slices.Contains([]string{"kimi-k2.6:cloud", "llama3.2"}, item.Name)
if isInstalled || isCloud { if isInstalled || isCloud {
if strings.HasSuffix(item.Description, "(not downloaded)") { if strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("installed or cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) t.Errorf("installed or cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
@@ -549,8 +549,8 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
if !cloudModels["glm-5.1:cloud"] { if !cloudModels["glm-5.1:cloud"] {
t.Error("glm-5.1:cloud should be in cloudModels") t.Error("glm-5.1:cloud should be in cloudModels")
} }
if !cloudModels["kimi-k2.5:cloud"] { if !cloudModels["kimi-k2.6:cloud"] {
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)") t.Error("kimi-k2.6:cloud should be in cloudModels (recommended cloud)")
} }
if !cloudModels["qwen3.5:cloud"] { if !cloudModels["qwen3.5:cloud"] {
t.Error("qwen3.5:cloud should be in cloudModels (recommended cloud)") t.Error("qwen3.5:cloud should be in cloudModels (recommended cloud)")
@@ -570,7 +570,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
for _, item := range items { for _, item := range items {
switch item.Name { switch item.Name {
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud": case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
if !item.Recommended { if !item.Recommended {
t.Errorf("%q should have Recommended=true", item.Name) t.Errorf("%q should have Recommended=true", item.Name)
} }
@@ -628,7 +628,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
lastRecIdx := -1 lastRecIdx := -1
firstNonRecIdx := len(got) firstNonRecIdx := len(got)
for i, name := range got { for i, name := range got {
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud" isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.6:cloud" || name == "qwen3.5:cloud"
if isRec && i > lastRecIdx { if isRec && i > lastRecIdx {
lastRecIdx = i lastRecIdx = i
} }

View File

@@ -21,7 +21,7 @@ import (
) )
var recommendedModels = []ModelItem{ var recommendedModels = []ModelItem{
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true}, {Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true}, {Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true}, {Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true}, {Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
@@ -56,6 +56,7 @@ var cloudModelLimits = map[string]cloudModelLimit{
"gpt-oss:20b": {Context: 131_072, Output: 131_072}, "gpt-oss:20b": {Context: 131_072, Output: 131_072},
"kimi-k2:1t": {Context: 262_144, Output: 262_144}, "kimi-k2:1t": {Context: 262_144, Output: 262_144},
"kimi-k2.5": {Context: 262_144, Output: 262_144}, "kimi-k2.5": {Context: 262_144, Output: 262_144},
"kimi-k2.6": {Context: 262_144, Output: 262_144},
"kimi-k2-thinking": {Context: 262_144, Output: 262_144}, "kimi-k2-thinking": {Context: 262_144, Output: 262_144},
"nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072}, "nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072},
"qwen3-coder:480b": {Context: 262_144, Output: 65_536}, "qwen3-coder:480b": {Context: 262_144, Output: 65_536},

View File

@@ -14,8 +14,6 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/mod/semver"
"github.com/ollama/ollama/api" "github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/envconfig"
@@ -98,9 +96,7 @@ func (c *Openclaw) Run(model string, args []string) error {
patchDeviceScopes() patchDeviceScopes()
} }
if ensureWebSearchPlugin() { configureOllamaWebSearch()
registerWebSearchPlugin()
}
// When extra args are passed through, run exactly what the user asked for // When extra args are passed through, run exactly what the user asked for
// after setup and skip the built-in gateway+TUI convenience flow. // after setup and skip the built-in gateway+TUI convenience flow.
@@ -738,89 +734,13 @@ func clearSessionModelOverride(primary string) {
_ = os.WriteFile(path, out, 0o600) _ = os.WriteFile(path, out, 0o600)
} }
const ( // configureOllamaWebSearch keeps launch-managed OpenClaw installs on the
webSearchNpmPackage = "@ollama/openclaw-web-search" // bundled Ollama web_search provider. Older launch builds installed an
webSearchMinVersion = "0.2.1" // external openclaw-web-search plugin that added custom ollama_web_search and
) // ollama_web_fetch tools. Current OpenClaw versions ship Ollama web_search as
// the bundled "ollama" plugin instead, so we migrate stale config and ensure
// ensureWebSearchPlugin installs the openclaw-web-search extension into the // fresh installs select the bundled provider.
// user-level extensions directory (~/.openclaw/extensions/) if it isn't already func configureOllamaWebSearch() {
// present, or re-installs if the installed version is older than webSearchMinVersion.
// Returns true if the extension is available.
func ensureWebSearchPlugin() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
if webSearchPluginUpToDate(pluginDir) {
return true
}
npmBin, err := exec.LookPath("npm")
if err != nil {
return false
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return false
}
// Download the tarball via `npm pack`, extract it flat into the plugin dir.
pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir)
out, err := pack.Output()
if err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset)
return false
}
tgzName := strings.TrimSpace(string(out))
tgzPath := filepath.Join(pluginDir, tgzName)
defer os.Remove(tgzPath)
tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir)
if err := tar.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset)
return false
}
fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset)
return true
}
// webSearchPluginUpToDate returns true if the plugin is installed and its
// package.json version is >= webSearchMinVersion.
func webSearchPluginUpToDate(pluginDir string) bool {
data, err := os.ReadFile(filepath.Join(pluginDir, "package.json"))
if err != nil {
return false
}
var pkg struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) != nil || pkg.Version == "" {
return false
}
return !versionLessThan(pkg.Version, webSearchMinVersion)
}
// versionLessThan compares two semver version strings (major.minor.patch).
// Inputs may omit the "v" prefix; it is added automatically for semver.Compare.
func versionLessThan(a, b string) bool {
if !strings.HasPrefix(a, "v") {
a = "v" + a
}
if !strings.HasPrefix(b, "v") {
b = "v" + b
}
return semver.Compare(a, b) < 0
}
// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw
// config so the gateway activates it on next start. Best-effort; silently returns
// on any error.
func registerWebSearchPlugin() {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return return
@@ -835,6 +755,8 @@ func registerWebSearchPlugin() {
return return
} }
stalePluginConfigured := false
plugins, _ := config["plugins"].(map[string]any) plugins, _ := config["plugins"].(map[string]any)
if plugins == nil { if plugins == nil {
plugins = make(map[string]any) plugins = make(map[string]any)
@@ -843,68 +765,100 @@ func registerWebSearchPlugin() {
if entries == nil { if entries == nil {
entries = make(map[string]any) entries = make(map[string]any)
} }
entries["openclaw-web-search"] = map[string]any{"enabled": true}
plugins["entries"] = entries
// Pin trust so the gateway doesn't warn about untracked plugins.
allow, _ := plugins["allow"].([]any)
hasAllow := false
for _, v := range allow {
if s, ok := v.(string); ok && s == "openclaw-web-search" {
hasAllow = true
break
}
}
if !hasAllow {
allow = append(allow, "openclaw-web-search")
}
plugins["allow"] = allow
// Record install provenance so the loader can verify the plugin origin.
installs, _ := plugins["installs"].(map[string]any)
if installs == nil {
installs = make(map[string]any)
}
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
installs["openclaw-web-search"] = map[string]any{
"source": "npm",
"spec": webSearchNpmPackage,
"installPath": pluginDir,
}
plugins["installs"] = installs
config["plugins"] = plugins
// Add plugin tools to tools.alsoAllow so they survive the coding profile's
// policy pipeline (which has an explicit allow list of core tools only).
tools, _ := config["tools"].(map[string]any) tools, _ := config["tools"].(map[string]any)
if tools == nil { if tools == nil {
tools = make(map[string]any) tools = make(map[string]any)
} }
alsoAllow, _ := tools["alsoAllow"].([]any)
needed := []string{"ollama_web_search", "ollama_web_fetch"}
have := make(map[string]bool, len(alsoAllow))
for _, v := range alsoAllow {
if s, ok := v.(string); ok {
have[s] = true
}
}
for _, name := range needed {
if !have[name] {
alsoAllow = append(alsoAllow, name)
}
}
tools["alsoAllow"] = alsoAllow
// Disable built-in web search/fetch since our plugin replaces them.
web, _ := tools["web"].(map[string]any) web, _ := tools["web"].(map[string]any)
if web == nil { if web == nil {
web = make(map[string]any) web = make(map[string]any)
} }
web["search"] = map[string]any{"enabled": false} search, _ := web["search"].(map[string]any)
web["fetch"] = map[string]any{"enabled": false} if search == nil {
search = make(map[string]any)
}
fetch, _ := web["fetch"].(map[string]any)
if fetch == nil {
fetch = make(map[string]any)
}
alsoAllow, _ := tools["alsoAllow"].([]any)
var filteredAlsoAllow []any
for _, v := range alsoAllow {
s, ok := v.(string)
if !ok {
filteredAlsoAllow = append(filteredAlsoAllow, v)
continue
}
if s == "ollama_web_search" || s == "ollama_web_fetch" {
stalePluginConfigured = true
continue
}
filteredAlsoAllow = append(filteredAlsoAllow, v)
}
if len(filteredAlsoAllow) > 0 {
tools["alsoAllow"] = filteredAlsoAllow
} else {
delete(tools, "alsoAllow")
}
if _, ok := entries["openclaw-web-search"]; ok {
delete(entries, "openclaw-web-search")
stalePluginConfigured = true
}
ollamaEntry, _ := entries["ollama"].(map[string]any)
if ollamaEntry == nil {
ollamaEntry = make(map[string]any)
}
ollamaEntry["enabled"] = true
entries["ollama"] = ollamaEntry
plugins["entries"] = entries
if allow, ok := plugins["allow"].([]any); ok {
var nextAllow []any
hasOllama := false
for _, v := range allow {
s, ok := v.(string)
if ok && s == "openclaw-web-search" {
stalePluginConfigured = true
continue
}
if ok && s == "ollama" {
hasOllama = true
}
nextAllow = append(nextAllow, v)
}
if !hasOllama {
nextAllow = append(nextAllow, "ollama")
}
plugins["allow"] = nextAllow
}
if installs, ok := plugins["installs"].(map[string]any); ok {
if _, exists := installs["openclaw-web-search"]; exists {
delete(installs, "openclaw-web-search")
stalePluginConfigured = true
}
if len(installs) > 0 {
plugins["installs"] = installs
} else {
delete(plugins, "installs")
}
}
if stalePluginConfigured || search["provider"] == nil {
search["provider"] = "ollama"
}
if stalePluginConfigured {
fetch["enabled"] = true
}
search["enabled"] = true
web["search"] = search
if len(fetch) > 0 {
web["fetch"] = fetch
}
tools["web"] = web tools["web"] = web
config["plugins"] = plugins
config["tools"] = tools config["tools"] = tools
out, err := json.MarshalIndent(config, "", " ") out, err := json.MarshalIndent(config, "", " ")

View File

@@ -2242,95 +2242,7 @@ func TestIntegrationOnboarded(t *testing.T) {
}) })
} }
func TestVersionLessThan(t *testing.T) { func TestConfigureOllamaWebSearch(t *testing.T) {
tests := []struct {
a, b string
want bool
}{
{"0.1.7", "0.2.1", true},
{"0.2.0", "0.2.1", true},
{"0.2.1", "0.2.1", false},
{"0.2.2", "0.2.1", false},
{"1.0.0", "0.2.1", false},
{"0.2.1", "1.0.0", true},
{"v0.1.7", "0.2.1", true},
{"0.2.1", "v0.2.1", false},
}
for _, tt := range tests {
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
if got := versionLessThan(tt.a, tt.b); got != tt.want {
t.Errorf("versionLessThan(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestWebSearchPluginUpToDate(t *testing.T) {
t.Run("missing directory", func(t *testing.T) {
if webSearchPluginUpToDate(filepath.Join(t.TempDir(), "nonexistent")) {
t.Error("expected false for missing directory")
}
})
t.Run("missing package.json", func(t *testing.T) {
dir := t.TempDir()
if webSearchPluginUpToDate(dir) {
t.Error("expected false for missing package.json")
}
})
t.Run("old version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.1.7"}`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for old version 0.1.7")
}
})
t.Run("exact minimum version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.2.1"}`), 0o644); err != nil {
t.Fatal(err)
}
if !webSearchPluginUpToDate(dir) {
t.Error("expected true for exact minimum version 0.2.1")
}
})
t.Run("newer version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
if !webSearchPluginUpToDate(dir) {
t.Error("expected true for newer version 1.0.0")
}
})
t.Run("invalid json", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`not json`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for invalid json")
}
})
t.Run("empty version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":""}`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for empty version")
}
})
}
func TestRegisterWebSearchPlugin(t *testing.T) {
home := t.TempDir() home := t.TempDir()
setTestHome(t, home) setTestHome(t, home)
@@ -2345,7 +2257,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registerWebSearchPlugin() configureOllamaWebSearch()
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -2361,40 +2273,30 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal("plugins section missing") t.Fatal("plugins section missing")
} }
// Check entries
entries, _ := plugins["entries"].(map[string]any) entries, _ := plugins["entries"].(map[string]any)
entry, _ := entries["openclaw-web-search"].(map[string]any) entry, _ := entries["ollama"].(map[string]any)
if enabled, _ := entry["enabled"].(bool); !enabled { if enabled, _ := entry["enabled"].(bool); !enabled {
t.Error("expected entries.openclaw-web-search.enabled = true") t.Error("expected entries.ollama.enabled = true")
}
if _, ok := entries["openclaw-web-search"]; ok {
t.Error("expected stale openclaw-web-search entry to be absent")
} }
// Check allow list if _, ok := plugins["allow"]; ok {
allow, _ := plugins["allow"].([]any) t.Error("did not expect plugins.allow to be created when no allowlist exists")
found := false
for _, v := range allow {
if s, ok := v.(string); ok && s == "openclaw-web-search" {
found = true
}
} }
if !found { if _, ok := plugins["installs"]; ok {
t.Error("expected plugins.allow to contain openclaw-web-search") t.Error("did not expect plugins.installs to be created")
} }
// Check install provenance tools, _ := config["tools"].(map[string]any)
installs, _ := plugins["installs"].(map[string]any) web, _ := tools["web"].(map[string]any)
record, _ := installs["openclaw-web-search"].(map[string]any) search, _ := web["search"].(map[string]any)
if record == nil { if got, _ := search["provider"].(string); got != "ollama" {
t.Fatal("expected plugins.installs.openclaw-web-search") t.Errorf("search provider = %q, want %q", got, "ollama")
} }
if source, _ := record["source"].(string); source != "npm" { if enabled, _ := search["enabled"].(bool); !enabled {
t.Errorf("install source = %q, want %q", source, "npm") t.Error("expected tools.web.search.enabled = true")
}
if spec, _ := record["spec"].(string); spec != webSearchNpmPackage {
t.Errorf("install spec = %q, want %q", spec, webSearchNpmPackage)
}
expectedPath := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
if installPath, _ := record["installPath"].(string); installPath != expectedPath {
t.Errorf("installPath = %q, want %q", installPath, expectedPath)
} }
}) })
@@ -2403,8 +2305,8 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registerWebSearchPlugin() configureOllamaWebSearch()
registerWebSearchPlugin() configureOllamaWebSearch()
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -2416,30 +2318,39 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
} }
plugins, _ := config["plugins"].(map[string]any) plugins, _ := config["plugins"].(map[string]any)
allow, _ := plugins["allow"].([]any) entries, _ := plugins["entries"].(map[string]any)
count := 0 if len(entries) != 1 {
for _, v := range allow { t.Fatalf("expected only bundled ollama entry, got %v", entries)
if s, ok := v.(string); ok && s == "openclaw-web-search" {
count++
}
} }
if count != 1 { if _, ok := entries["ollama"]; !ok {
t.Errorf("expected exactly 1 openclaw-web-search in allow, got %d", count) t.Fatalf("expected entries.ollama to exist, got %v", entries)
} }
}) })
t.Run("preserves existing config", func(t *testing.T) { t.Run("migrates stale plugin config and preserves unrelated settings", func(t *testing.T) {
initial := map[string]any{ initial := map[string]any{
"plugins": map[string]any{ "plugins": map[string]any{
"allow": []any{"some-other-plugin"}, "allow": []any{"some-other-plugin", "openclaw-web-search"},
"entries": map[string]any{ "entries": map[string]any{
"some-other-plugin": map[string]any{"enabled": true}, "some-other-plugin": map[string]any{"enabled": true},
"openclaw-web-search": map[string]any{"enabled": true},
}, },
"installs": map[string]any{ "installs": map[string]any{
"some-other-plugin": map[string]any{ "some-other-plugin": map[string]any{
"source": "npm", "source": "npm",
"installPath": "/some/path", "installPath": "/some/path",
}, },
"openclaw-web-search": map[string]any{
"source": "npm",
"installPath": "/old/path",
},
},
},
"tools": map[string]any{
"alsoAllow": []any{"ollama_web_search", "ollama_web_fetch", "browser"},
"web": map[string]any{
"search": map[string]any{"enabled": false},
"fetch": map[string]any{"enabled": false},
}, },
}, },
"customField": "preserved", "customField": "preserved",
@@ -2449,7 +2360,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registerWebSearchPlugin() configureOllamaWebSearch()
out, err := os.ReadFile(configPath) out, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -2469,28 +2380,61 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
if entries["some-other-plugin"] == nil { if entries["some-other-plugin"] == nil {
t.Error("existing plugin entry was lost") t.Error("existing plugin entry was lost")
} }
if entries["openclaw-web-search"] != nil {
t.Error("stale openclaw-web-search entry should be removed")
}
if ollamaEntry, _ := entries["ollama"].(map[string]any); ollamaEntry == nil {
t.Fatal("expected bundled ollama entry to be enabled")
}
installs, _ := plugins["installs"].(map[string]any) installs, _ := plugins["installs"].(map[string]any)
if installs["some-other-plugin"] == nil { if installs["some-other-plugin"] == nil {
t.Error("existing install record was lost") t.Error("existing install record was lost")
} }
if installs["openclaw-web-search"] != nil {
t.Error("stale openclaw-web-search install record should be removed")
}
allow, _ := plugins["allow"].([]any) allow, _ := plugins["allow"].([]any)
hasOther, hasWebSearch := false, false hasOther, hasStalePlugin, hasOllama := false, false, false
for _, v := range allow { for _, v := range allow {
s, _ := v.(string) s, _ := v.(string)
if s == "some-other-plugin" { if s == "some-other-plugin" {
hasOther = true hasOther = true
} }
if s == "openclaw-web-search" { if s == "openclaw-web-search" {
hasWebSearch = true hasStalePlugin = true
}
if s == "ollama" {
hasOllama = true
} }
} }
if !hasOther { if !hasOther {
t.Error("existing allow entry was lost") t.Error("existing allow entry was lost")
} }
if !hasWebSearch { if hasStalePlugin {
t.Error("openclaw-web-search not added to allow") t.Error("stale openclaw-web-search allow entry should be removed")
}
if !hasOllama {
t.Error("expected plugins.allow to contain bundled ollama plugin")
}
tools, _ := config["tools"].(map[string]any)
alsoAllow, _ := tools["alsoAllow"].([]any)
if len(alsoAllow) != 1 || alsoAllow[0] != "browser" {
t.Errorf("expected stale custom web tools to be removed, got %v", alsoAllow)
}
web, _ := tools["web"].(map[string]any)
search, _ := web["search"].(map[string]any)
fetch, _ := web["fetch"].(map[string]any)
if got, _ := search["provider"].(string); got != "ollama" {
t.Errorf("search provider = %q, want %q", got, "ollama")
}
if enabled, _ := search["enabled"].(bool); !enabled {
t.Error("expected migrated tools.web.search.enabled = true")
}
if enabled, _ := fetch["enabled"].(bool); !enabled {
t.Error("expected migrated tools.web.fetch.enabled = true")
} }
}) })
} }

View File

@@ -2,6 +2,10 @@
title: Structured Outputs title: Structured Outputs
--- ---
<Note>
Ollama's Cloud currently does not support structured outputs.
</Note>
Structured outputs let you enforce a JSON schema on model responses so you can reliably extract structured data, describe images, or keep every reply consistent. Structured outputs let you enforce a JSON schema on model responses so you can reliably extract structured data, describe images, or keep every reply consistent.
## Generating structured JSON ## Generating structured JSON

View File

@@ -15,7 +15,7 @@ Ollama handles everything automatically:
1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm 1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm
2. **Security** — On the first launch, a security notice explains the risks of tool access 2. **Security** — On the first launch, a security notice explains the risks of tool access
3. **Model** — Pick a model from the selector (local or cloud) 3. **Model** — Pick a model from the selector (local or cloud)
4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and installs the web search and fetch plugin 4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and enables OpenClaw's bundled Ollama web search
5. **Gateway** — Starts in the background and opens the OpenClaw TUI 5. **Gateway** — Starts in the background and opens the OpenClaw TUI
<Note>OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information.</Note> <Note>OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information.</Note>
@@ -24,19 +24,19 @@ Ollama handles everything automatically:
## Web search and fetch ## Web search and fetch
OpenClaw ships with a web search and fetch plugin that gives local or cloud models the ability to search the web and extract readable page content. OpenClaw ships with a bundled Ollama `web_search` provider that lets local or cloud-backed Ollama setups search the web through the configured Ollama host.
```bash ```bash
ollama launch openclaw ollama launch openclaw
``` ```
Web search and fetch is enabled automatically when launching OpenClaw through Ollama. To install the plugin directly: Ollama web search is enabled automatically when launching OpenClaw through Ollama. To configure it manually:
```bash ```bash
openclaw plugins install @ollama/openclaw-web-search openclaw configure --section web
``` ```
<Note>Web search for local models requires `ollama signin`.</Note> <Note>Ollama web search for local models requires `ollama signin`.</Note>
## Configure without launching ## Configure without launching
@@ -93,4 +93,3 @@ Link WhatsApp, Telegram, Slack, Discord, or iMessage to chat with your local mod
```bash ```bash
openclaw gateway stop openclaw gateway stop
``` ```