// Ollama tests cover web search provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; 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 { testing, createOllamaWebSearchProvider, runOllamaWebSearch, } from "./web-search-provider.js"; const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); type OllamaProviderConfigOverride = Partial<{ api: "ollama"; apiKey: string; baseUrl: string; baseURL: string; models: NonNullable< NonNullable["providers"]>[string] >["models"]; }>; function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenClawConfig { return { models: { providers: { ollama: { baseUrl: "http://ollama.local:11434/v1", api: "ollama", models: [], ...provider, }, }, }, }; } function createOllamaConfigWithWebSearchBaseUrl(baseUrl: string): OpenClawConfig { return { ...createOllamaConfig(), plugins: { entries: { ollama: { config: { webSearch: { baseUrl, }, }, }, }, }, }; } function createSetupNotes() { const notes: Array<{ title?: string; message: string }> = []; return { notes, prompter: { note: async (message: string, title?: string) => { notes.push({ title, message }); }, }, }; } function expectOllamaWebSearchRequest( call: unknown[] | undefined, params: { url: string; query?: string; maxResults?: number; headers?: Record; policy: Record; }, ) { if (!call?.[0] || typeof call[0] !== "object") { throw new Error("Expected fetchWithSsrFGuard call"); } const request = call[0] as { url: string; init: { method: string; headers: Record; body: string; signal: AbortSignal; }; policy: Record; auditContext: string; }; expect(request).toEqual({ url: params.url, init: { method: "POST", headers: params.headers ?? { "Content-Type": "application/json" }, body: JSON.stringify({ query: params.query ?? "openclaw", max_results: params.maxResults ?? 5, }), signal: request.init.signal, }, policy: params.policy, auditContext: "ollama-web-search.search", }); expect(request.init.signal).toBeInstanceOf(AbortSignal); } function fetchCall(index = 0): unknown[] { const call = fetchWithSsrFGuardMock.mock.calls.at(index); if (!call) { throw new Error(`expected guarded fetch call ${index}`); } return call; } function fetchRequest(index = 0): { init?: { headers?: Record }; url?: string; } { const request = fetchCall(index).at(0); if (!request || typeof request !== "object") { throw new Error(`expected guarded fetch request ${index}`); } return request as { init?: { headers?: Record }; url?: string; }; } function expectSingleSearchResultUrl(results: unknown, url: string) { if (!Array.isArray(results)) { throw new Error("Expected search results array"); } expect(results).toHaveLength(1); const [result] = results; if (!result || typeof result !== "object") { throw new Error("Expected search result object"); } expect((result as { url?: unknown }).url).toBe(url); } describe("ollama web search provider", () => { beforeEach(() => { fetchWithSsrFGuardMock.mockReset(); }); it("registers a keyless web search provider", () => { const provider = createContractOllamaWebSearchProvider(); expect(provider.id).toBe("ollama"); expect(provider.label).toBe("Ollama Web Search"); expect(provider.requiresCredential).toBe(false); expect(provider.envVars).toEqual([]); }); it("uses the configured Ollama host and enables the plugin in config", () => { const provider = createOllamaWebSearchProvider(); if (!provider.applySelectionConfig) { throw new Error("Expected applySelectionConfig to be defined"); } const applied = provider.applySelectionConfig({}); expect(provider.credentialPath).toBe(""); expect(applied.plugins?.entries?.ollama?.enabled).toBe(true); expect( testing.resolveOllamaWebSearchBaseUrl({ models: { providers: { ollama: { baseUrl: "http://ollama.local:11434/v1", api: "ollama", models: [], }, }, }, }), ).toBe("http://ollama.local:11434"); }); it("prefers the plugin web search base URL over the model provider host", () => { expect( testing.resolveOllamaWebSearchBaseUrl( createOllamaConfigWithWebSearchBaseUrl("http://localhost:11434/v1"), ), ).toBe("http://localhost:11434"); }); it("uses the configured Ollama Cloud host for web search", () => { expect( testing.resolveOllamaWebSearchBaseUrl( createOllamaConfig({ baseUrl: "https://ollama.com", }), ), ).toBe("https://ollama.com"); }); it("uses the model provider baseURL alias for web search", () => { expect( testing.resolveOllamaWebSearchBaseUrl( createOllamaConfig({ baseUrl: undefined, baseURL: "http://remote-ollama:11434/v1", } as OllamaProviderConfigOverride), ), ).toBe("http://remote-ollama:11434"); }); it("maps generic search args into the local Ollama proxy endpoint", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response( JSON.stringify({ results: [ { title: "OpenClaw", url: "https://openclaw.ai/docs", content: "Gateway docs and setup details", }, ], }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), release, }); const provider = createOllamaWebSearchProvider(); const tool = provider.createTool({ config: createOllamaConfig(), } as never); if (!tool) { throw new Error("Expected tool definition"); } const result = await tool.execute({ query: "openclaw docs", count: 3 }); expectOllamaWebSearchRequest(fetchCall(), { url: "http://ollama.local:11434/api/experimental/web_search", query: "openclaw docs", maxResults: 3, policy: { allowPrivateNetwork: true, hostnameAllowlist: ["ollama.local"], }, }); expect(result.query).toBe("openclaw docs"); expect(result.provider).toBe("ollama"); expect(result.count).toBe(1); expectSingleSearchResultUrl(result.results, "https://openclaw.ai/docs"); expect(release).toHaveBeenCalledTimes(1); }); it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => { fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response("not found", { status: 404 }), release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ response: new Response( JSON.stringify({ results: [{ title: "Legacy", url: "https://example.com", content: "result" }], }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), release: vi.fn(async () => {}), }); const result = await runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw", }); expect(result.count).toBe(1); expectSingleSearchResultUrl(result.results, "https://example.com"); 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", ]); }); 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 () => {}), }); const result = await runOllamaWebSearch({ config: createOllamaConfig({ baseUrl: "https://ollama.com", apiKey: "cloud-config-secret", }), query: "openclaw", }); expect(result.count).toBe(1); expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1); expect(fetchRequest().url).toBe("https://ollama.com/api/web_search"); expectOllamaWebSearchRequest(fetchCall(), { url: "https://ollama.com/api/web_search", headers: { "Content-Type": "application/json", Authorization: "Bearer cloud-config-secret", }, policy: { allowPrivateNetwork: true, hostnameAllowlist: ["ollama.com"], }, }); }); it("uses an env Ollama key only for the cloud fallback from a local host", async () => { const original = process.env.OLLAMA_API_KEY; try { process.env.OLLAMA_API_KEY = "cloud-secret"; fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response("not found", { status: 404 }), release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ response: new Response("not found", { status: 404 }), release: vi.fn(async () => {}), }) .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 () => {}), }); const result = await runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw", }); expect(result.count).toBe(1); const firstHeaders = fetchRequest().init?.headers; const cloudHeaders = fetchRequest(2).init?.headers; 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(fetchRequest(2).url).toBe("https://ollama.com/api/web_search"); } finally { if (original === undefined) { delete process.env.OLLAMA_API_KEY; } else { process.env.OLLAMA_API_KEY = original; } } }); it("surfaces Ollama signin guidance for 401 responses", async () => { fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response("", { status: 401 }), release: vi.fn(async () => {}), }); await expect(runOllamaWebSearch({ query: "latest openclaw release" })).rejects.toThrow( "ollama signin", ); }); it("reports malformed Ollama web search JSON with a stable provider error", async () => { fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response("{ nope", { status: 200 }), release: vi.fn(async () => {}), }); await expect( runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw", }), ).rejects.toThrow("Ollama web search: malformed JSON response"); }); it("bounds successful Ollama web search JSON bodies before parsing", async () => { const streamed = createStreamingResponse({ chunkCount: 32, chunkSize: 1024 * 1024, text: "x", headers: { "content-type": "application/json" }, }); const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: streamed.response, release: vi.fn(async () => {}), }); await expect( runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw", }), ).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes"); expect(streamed.getReadCount()).toBeLessThan(32); expect(streamed.wasCanceled()).toBe(true); expect(jsonSpy).not.toHaveBeenCalled(); }); it("warns when Ollama is not reachable during setup without cancelling", async () => { fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("connect failed")); const config = createOllamaConfig(); const { notes, prompter } = createSetupNotes(); const next = await testing.warnOllamaWebSearchPrereqs({ config, prompter, }); expect(next).toBe(config); expect(notes).toEqual([ { title: "Ollama Web Search", message: [ "Ollama Web Search requires Ollama to be running.", "Expected host: http://ollama.local:11434", "Start Ollama before using this provider.", ].join("\n"), }, ]); }); it("resolves env var when config apiKey is a marker string", () => { const original = process.env.OLLAMA_API_KEY; try { process.env.OLLAMA_API_KEY = "real-secret-from-env"; const key = testing.resolveOllamaWebSearchApiKey( createOllamaConfig({ apiKey: "OLLAMA_API_KEY", baseUrl: "http://localhost:11434", }), ); expect(key).toBe("real-secret-from-env"); } finally { if (original === undefined) { delete process.env.OLLAMA_API_KEY; } else { process.env.OLLAMA_API_KEY = original; } } }); it("warns when ollama signin is missing during setup without cancelling", async () => { fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response(JSON.stringify({ models: [] }), { status: 200, headers: { "Content-Type": "application/json" }, }), release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ response: new Response( JSON.stringify({ error: "not signed in", signin_url: "https://ollama.com/signin" }), { status: 401, headers: { "Content-Type": "application/json" }, }, ), release: vi.fn(async () => {}), }); const config = createOllamaConfig(); const { notes, prompter } = createSetupNotes(); const next = await testing.warnOllamaWebSearchPrereqs({ config, prompter, }); expect(next).toBe(config); expect(notes).toEqual([ { title: "Ollama Web Search", message: "Ollama Web Search requires `ollama signin`.\nhttps://ollama.com/signin", }, ]); }); });