mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
079f8190ed
commit
0dfd87ebe7
+1
-1
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user