From 0f672dcc738e820ce1ea1037ac5196db9d22a1a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 01:10:23 +0100 Subject: [PATCH] fix(ollama): align web search endpoint routing --- CHANGELOG.md | 2 +- docs/tools/ollama-search.md | 6 +- .../ollama/src/web-search-provider.test.ts | 44 ++++++++++-- extensions/ollama/src/web-search-provider.ts | 70 +++++++++++++------ 4 files changed, 92 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6081dc1759a..fa3b26a87e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n. - Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys. - Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang. -- Providers/Ollama: try both current and legacy Ollama web-search endpoints and use `OLLAMA_API_KEY` only for the `ollama.com` cloud fallback, keeping local signed-in hosts keyless. Fixes #69132. Thanks @yoon1012 and @hyspacex. +- Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex. - Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81. - Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n. - Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc. diff --git a/docs/tools/ollama-search.md b/docs/tools/ollama-search.md index 073cb39d7c1..280748e6637 100644 --- a/docs/tools/ollama-search.md +++ b/docs/tools/ollama-search.md @@ -92,8 +92,10 @@ for requests to that configured host. it does not block selection. - Runtime auto-detect can fall back to Ollama Web Search when no higher-priority credentialed provider is configured. -- The provider tries Ollama's `/api/web_search` endpoint first, then the legacy - `/api/experimental/web_search` endpoint for older hosts. +- Local Ollama daemon hosts use the local proxy endpoint + `/api/experimental/web_search`, which signs and forwards to Ollama Cloud. +- `https://ollama.com` hosts use the public hosted endpoint + `/api/web_search` directly with bearer API-key auth. ## Related diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index 4d70d28f51c..2b82bc49752 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -125,7 +125,7 @@ describe("ollama web search provider", () => { ).toBe("https://ollama.com"); }); - it("maps generic search args into the Ollama search endpoint", async () => { + it("maps generic search args into the local Ollama proxy endpoint", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response( @@ -157,7 +157,7 @@ describe("ollama web search provider", () => { expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( expect.objectContaining({ - url: "http://ollama.local:11434/api/web_search", + url: "http://ollama.local:11434/api/experimental/web_search", auditContext: "ollama-web-search.search", }), ); @@ -184,7 +184,7 @@ describe("ollama web search provider", () => { expect(release).toHaveBeenCalledTimes(1); }); - it("falls back to the legacy Ollama web search endpoint when /api/web_search is missing", async () => { + it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => { fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response("not found", { status: 404 }), @@ -211,11 +211,42 @@ describe("ollama web search provider", () => { }); expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([ - "http://ollama.local:11434/api/web_search", "http://ollama.local:11434/api/experimental/web_search", + "http://ollama.local:11434/api/web_search", ]); }); + it("uses only the hosted endpoint for Ollama Cloud base URLs", async () => { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + results: [{ title: "Cloud", url: "https://example.com", content: "result" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }); + + await expect( + runOllamaWebSearch({ + config: createOllamaConfig({ + baseUrl: "https://ollama.com", + apiKey: "cloud-config-secret", + }), + query: "openclaw", + }), + ).resolves.toMatchObject({ count: 1 }); + + expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1); + expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].url).toBe("https://ollama.com/api/web_search"); + expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].init?.headers).toMatchObject({ + Authorization: "Bearer cloud-config-secret", + }); + }); + it("uses an env Ollama key only for the cloud fallback from a local host", async () => { const original = process.env.OLLAMA_API_KEY; try { @@ -256,6 +287,11 @@ describe("ollama web search provider", () => { | undefined; expect(firstHeaders?.Authorization).toBeUndefined(); expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret"); + expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([ + "http://ollama.local:11434/api/experimental/web_search", + "http://ollama.local:11434/api/web_search", + "https://ollama.com/api/web_search", + ]); expect(fetchWithSsrFGuardMock.mock.calls[2]?.[0].url).toBe( "https://ollama.com/api/web_search", ); diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index c4ed075ff2f..79399ca8b21 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -41,8 +41,8 @@ const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object( { additionalProperties: false }, ); -const OLLAMA_WEB_SEARCH_PATH = "/api/web_search"; -const OLLAMA_LEGACY_WEB_SEARCH_PATH = "/api/experimental/web_search"; +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; @@ -58,6 +58,12 @@ type OllamaWebSearchResponse = { results?: OllamaWebSearchResult[]; }; +type OllamaWebSearchAttempt = { + baseUrl: string; + path: string; + apiKey?: string; +}; + function isOllamaCloudBaseUrl(baseUrl: string): boolean { try { const parsed = new URL(baseUrl); @@ -111,6 +117,43 @@ function normalizeOllamaWebSearchResult( }; } +function buildOllamaWebSearchAttempts(params: { + baseUrl: string; + configuredApiKey?: string; + envApiKey?: string; +}): OllamaWebSearchAttempt[] { + if (isOllamaCloudBaseUrl(params.baseUrl)) { + return [ + { + baseUrl: params.baseUrl, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.configuredApiKey ?? params.envApiKey, + }, + ]; + } + + const attempts: OllamaWebSearchAttempt[] = [ + { + baseUrl: params.baseUrl, + path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH, + apiKey: params.configuredApiKey, + }, + { + baseUrl: params.baseUrl, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.configuredApiKey, + }, + ]; + if (params.envApiKey) { + attempts.push({ + baseUrl: OLLAMA_CLOUD_BASE_URL, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.envApiKey, + }); + } + return attempts; +} + export async function runOllamaWebSearch(params: { config?: OpenClawConfig; query: string; @@ -127,27 +170,7 @@ export async function runOllamaWebSearch(params: { const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT); const startedAt = Date.now(); const body = JSON.stringify({ query, max_results: count }); - const attempts = [ - { - baseUrl, - path: OLLAMA_WEB_SEARCH_PATH, - apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey, - }, - { - baseUrl, - path: OLLAMA_LEGACY_WEB_SEARCH_PATH, - apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey, - }, - ...(!isOllamaCloudBaseUrl(baseUrl) && envApiKey - ? [ - { - baseUrl: OLLAMA_CLOUD_BASE_URL, - path: OLLAMA_WEB_SEARCH_PATH, - apiKey: envApiKey, - }, - ] - : []), - ]; + const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey }); let payload: OllamaWebSearchResponse | undefined; let lastError: Error | undefined; @@ -305,6 +328,7 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin { } export const __testing = { + buildOllamaWebSearchAttempts, normalizeOllamaWebSearchResult, resolveConfiguredOllamaWebSearchApiKey, resolveEnvOllamaWebSearchApiKey,