diff --git a/docs/help/faq.md b/docs/help/faq.md
index b49459d37eba..c87f27be70cb 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -608,7 +608,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
| Grok | No (xAI OAuth or key) | `XAI_API_KEY` |
| Kimi | No | `KIMI_API_KEY` or `MOONSHOT_API_KEY` |
| MiniMax Search | No | `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`, or `MINIMAX_API_KEY` |
- | Ollama Web Search | Yes (needs `ollama signin`) | - |
+ | Ollama Web Search | Local: yes (needs `ollama signin`); hosted: no | Hosted: `OLLAMA_API_KEY` |
| Perplexity | No | `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` |
| SearXNG | Yes (self-hosted) | `SEARXNG_BASE_URL` |
| Tavily | No | `TAVILY_API_KEY` |
diff --git a/docs/tools/ollama-search.md b/docs/tools/ollama-search.md
index 154bead4394c..49f46e91285e 100644
--- a/docs/tools/ollama-search.md
+++ b/docs/tools/ollama-search.md
@@ -17,6 +17,11 @@ Ollama host plus `ollama signin`. Direct hosted search (no local Ollama) needs
## Setup
+If you already use Ollama for models, Ollama Web Search reuses the same
+configured host.
+
+### Local Ollama
+
Make sure Ollama is installed and running.
@@ -36,8 +41,15 @@ Ollama host plus `ollama signin`. Direct hosted search (no local Ollama) needs
-If you already use Ollama for models, Ollama Web Search reuses the same
-configured host.
+### Hosted Ollama
+
+1. Create an [Ollama API key](https://docs.ollama.com/api/authentication#api-keys)
+ and set `OLLAMA_API_KEY` in the Gateway environment.
+2. Set `models.providers.ollama.baseUrl` to `https://ollama.com`; see
+ [Config](#config).
+3. Run `openclaw configure --section web` and select **Ollama Web Search**.
+
+Hosted search does not require a local Ollama daemon or `ollama signin`.
OpenClaw never auto-selects Ollama Web Search over a higher-priority
@@ -134,8 +146,9 @@ Direct hosted Ollama Web Search (no local Ollama):
and `OLLAMA_API_KEY` is set, it retries once against
`https://ollama.com/api/web_search` with that key — without sending it to
the local host.
-- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
- does not block selecting the provider.
+- OpenClaw warns during setup if a local Ollama host is unreachable or not
+ signed in, or if hosted search has no API key. These warnings do not block
+ selecting the provider.
## Related
diff --git a/extensions/ollama/src/web-search-provider.runtime.ts b/extensions/ollama/src/web-search-provider.runtime.ts
index 33a7974a607f..16705880aae7 100644
--- a/extensions/ollama/src/web-search-provider.runtime.ts
+++ b/extensions/ollama/src/web-search-provider.runtime.ts
@@ -22,7 +22,7 @@ import {
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
-import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
+import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
@@ -37,10 +37,11 @@ import {
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
-const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
+const OLLAMA_CLOUD_WEB_SEARCH_AUTH_ERROR =
+ "Hosted Ollama Web Search requires an API key. Set OLLAMA_API_KEY or configure models.providers.ollama.apiKey.";
type OllamaWebSearchResult = {
title?: string;
@@ -220,7 +221,11 @@ async function runOllamaWebSearch(params: {
try {
if (response.status === 401) {
- throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
+ throw new Error(
+ isOllamaCloudBaseUrl(attempt.baseUrl)
+ ? OLLAMA_CLOUD_WEB_SEARCH_AUTH_ERROR
+ : "Ollama web search authentication failed. Run `ollama signin`.",
+ );
}
if (response.status === 403) {
throw new Error(
@@ -243,11 +248,7 @@ async function runOllamaWebSearch(params: {
params.signal?.throwIfAborted();
break;
} catch (error) {
- if (error instanceof Error) {
- lastError = error;
- } else {
- lastError = new Error(String(error));
- }
+ lastError = error instanceof Error ? error : new Error(String(error));
throw lastError;
} finally {
// The 401/403 branches throw before the stream is touched, leaving release
@@ -301,14 +302,20 @@ async function warnOllamaWebSearchPrereqs(params: {
};
}): Promise {
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
+ if (isOllamaCloudBaseUrl(baseUrl)) {
+ if (
+ !resolveConfiguredOllamaWebSearchApiKey(params.config) &&
+ !resolveEnvOllamaWebSearchApiKey()
+ ) {
+ await params.prompter.note(OLLAMA_CLOUD_WEB_SEARCH_AUTH_ERROR, "Ollama Web Search");
+ }
+ return params.config;
+ }
+
const { reachable } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(
- [
- "Ollama Web Search requires Ollama to be running.",
- `Expected host: ${baseUrl}`,
- "Start Ollama before using this provider.",
- ].join("\n"),
+ `Ollama Web Search requires Ollama to be running.\nExpected host: ${baseUrl}\nStart Ollama before using this provider.`,
"Ollama Web Search",
);
return params.config;
@@ -318,10 +325,7 @@ async function warnOllamaWebSearchPrereqs(params: {
const auth = await checkOllamaCloudAuth(baseUrl);
if (!auth.signedIn) {
await params.prompter.note(
- [
- "Ollama Web Search requires `ollama signin`.",
- ...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]),
- ].join("\n"),
+ `Ollama Web Search requires \`ollama signin\`.\n${auth.signinUrl ?? "Run `ollama signin`."}`,
"Ollama Web Search",
);
}
@@ -345,11 +349,7 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
getCredentialValue: () => undefined,
setCredentialValue: () => {},
applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config,
- runSetup: async (ctx) =>
- await warnOllamaWebSearchPrereqs({
- config: ctx.config,
- prompter: ctx.prompter,
- }),
+ runSetup: async (ctx) => await warnOllamaWebSearchPrereqs(ctx),
createTool: (ctx) => ({
description: OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION,
parameters: OLLAMA_WEB_SEARCH_TOOL_PARAMETERS,
diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts
index 5e337c760c3f..c0fc32901179 100644
--- a/extensions/ollama/src/web-search-provider.test.ts
+++ b/extensions/ollama/src/web-search-provider.test.ts
@@ -5,6 +5,7 @@ import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
+import { createLazyOllamaWebSearchProvider } from "./web-search-provider-registration.js";
import { createOllamaWebSearchProvider } from "./web-search-provider.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
@@ -40,8 +41,10 @@ function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenCl
};
}
-async function runOllamaWebSearchSetup(config: OpenClawConfig) {
- const provider = createOllamaWebSearchProvider();
+async function runOllamaWebSearchSetup(
+ config: OpenClawConfig,
+ provider = createOllamaWebSearchProvider(),
+) {
if (!provider.runSetup) {
throw new Error("Expected Ollama web search setup");
}
@@ -460,6 +463,14 @@ describe("ollama web search provider", () => {
);
});
+ it("surfaces API-key guidance for hosted Ollama 401 responses", async () => {
+ fetchWithSsrFGuardMock.mockResolvedValue(guardedResponse("", { status: 401 }));
+
+ await expect(
+ runOllamaWebSearch(createOllamaConfig({ baseUrl: "https://ollama.com" })),
+ ).rejects.toThrow("Set OLLAMA_API_KEY or configure models.providers.ollama.apiKey");
+ });
+
it("reports malformed Ollama web search JSON with a stable provider error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce(guardedResponse("{ nope"));
@@ -503,6 +514,89 @@ describe("ollama web search provider", () => {
);
});
+ it.each([
+ {
+ name: "the configured model provider",
+ config: createOllamaConfig({ baseUrl: "https://ollama.com", apiKey: "hosted-test-key" }),
+ },
+ {
+ name: "the plugin's higher-priority web search override",
+ config: {
+ ...createOllamaConfig({ apiKey: "hosted-test-key" }),
+ plugins: {
+ entries: {
+ ollama: { config: { webSearch: { baseUrl: "https://ollama.com/v1" } } },
+ },
+ },
+ },
+ },
+ ])("does not require a local daemon when $name selects hosted search", async ({ config }) => {
+ fetchWithSsrFGuardMock
+ .mockResolvedValueOnce(guardedResponse({ models: [] }))
+ .mockResolvedValueOnce(guardedResponse({ error: "not signed in" }, { status: 401 }));
+
+ const { next, notes } = await runOllamaWebSearchSetup(
+ config,
+ createLazyOllamaWebSearchProvider(),
+ );
+
+ expect(next).toBe(config);
+ expect(notes).toEqual([]);
+ expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ {
+ name: "the configured model provider",
+ config: createOllamaConfig({ baseUrl: "https://ollama.com" }),
+ },
+ {
+ name: "the plugin's higher-priority web search override",
+ config: {
+ ...createOllamaConfig(),
+ plugins: {
+ entries: {
+ ollama: { config: { webSearch: { baseUrl: "https://ollama.com/v1" } } },
+ },
+ },
+ },
+ },
+ ])("warns when $name selects hosted search without an API key", async ({ config }) => {
+ await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => {
+ const { next, notes } = await runOllamaWebSearchSetup(
+ config,
+ createLazyOllamaWebSearchProvider(),
+ );
+
+ expect(next).toBe(config);
+ expect(notes).toEqual([
+ {
+ title: "Ollama Web Search",
+ message:
+ "Hosted Ollama Web Search requires an API key. Set OLLAMA_API_KEY or configure models.providers.ollama.apiKey.",
+ },
+ ]);
+ expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
+ });
+ });
+
+ it("accepts the ambient API key for hosted Ollama search setup", async () => {
+ await withEnvAsync({ OLLAMA_API_KEY: "hosted-env-key" }, async () => {
+ const config = createOllamaConfig({
+ baseUrl: "https://ollama.com",
+ apiKey: "OLLAMA_API_KEY",
+ });
+ const { next, notes } = await runOllamaWebSearchSetup(
+ config,
+ createLazyOllamaWebSearchProvider(),
+ );
+
+ expect(next).toBe(config);
+ expect(notes).toEqual([]);
+ expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
+ });
+ });
+
it("resolves env var when config apiKey is a marker string", async () => {
await withEnvAsync({ OLLAMA_API_KEY: "real-secret-from-env" }, async () => {
mockSuccessfulSearchResponse();