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
This commit is contained in:
Peter Steinberger
2026-08-26 00:37:35 -07:00
committed by GitHub
parent 079f8190ed
commit 0dfd87ebe7
4 changed files with 136 additions and 29 deletions
+1 -1
View File
@@ -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` |
+17 -4
View File
@@ -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
<Steps>
<Step title="Start Ollama">
Make sure Ollama is installed and running.
@@ -36,8 +41,15 @@ Ollama host plus `ollama signin`. Direct hosted search (no local Ollama) needs
</Step>
</Steps>
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`.
<Note>
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
@@ -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<OpenClawConfig> {
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,
@@ -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();