From 0dfd87ebe7817c3e8e1fe80af8ad7ad5da21aa89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 00:37:35 -0700 Subject: [PATCH] fix(ollama): skip local checks for hosted web search (#129833) * fix(ollama): skip local checks for hosted web search * fix(ollama): explain hosted search API key requirements * docs(ollama): separate hosted and local search setup --- docs/help/faq.md | 2 +- docs/tools/ollama-search.md | 21 +++- .../ollama/src/web-search-provider.runtime.ts | 44 ++++----- .../ollama/src/web-search-provider.test.ts | 98 ++++++++++++++++++- 4 files changed, 136 insertions(+), 29 deletions(-) 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();