diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index c092d53d7e..893f185aac 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -186,6 +186,22 @@ export const SEARCH_PROVIDERS: Record = { timeoutMs: 10_000, cacheTTLMs: 3 * 60 * 1000, }, + + "ollama-search": { + id: "ollama-search", + name: "Ollama Search", + baseUrl: "https://ollama.com/api/web_search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + freeMonthlyQuota: 1000, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 10, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, }; /** @@ -194,6 +210,7 @@ export const SEARCH_PROVIDERS: Record = { */ export const SEARCH_CREDENTIAL_FALLBACKS: Record = { "perplexity-search": "perplexity", + "ollama-search": "ollama-cloud", }; /** diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 01e2e68973..55b8e3a4f2 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -3,9 +3,9 @@ import { randomUUID } from "crypto"; * Search Handler * * Handles POST /v1/search requests. - * Routes to 10 search providers with automatic failover: + * Routes to 11 search providers with automatic failover: * serper-search, brave-search, perplexity-search, exa-search, tavily-search, - * google-pse-search, linkup-search, searchapi-search, youcom-search, searxng-search + * google-pse-search, linkup-search, searchapi-search, youcom-search, searxng-search, ollama-search * * Request format: * { @@ -582,6 +582,26 @@ function buildSearxngRequest( }; } +function buildOllamaRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + return { + url: resolveSearchBaseUrl(config, params), + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify({ + query: params.query, + max_results: params.maxResults, + }), + }, + }; +} + function buildRequest( config: SearchProviderConfig, params: SearchRequestParams @@ -596,6 +616,7 @@ function buildRequest( if (config.id === "searchapi-search") return buildSearchApiRequest(config, params); if (config.id === "youcom-search") return buildYouComRequest(config, params); if (config.id === "searxng-search") return buildSearxngRequest(config, params); + if (config.id === "ollama-search") return buildOllamaRequest(config, params); // Fallback for future providers: POST with bearer auth return { url: resolveSearchBaseUrl(config, params), @@ -881,6 +902,32 @@ function normalizeSearxngResponse( return { results, totalResults: results.length }; } +function normalizeOllamaResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = Array.isArray(data?.results) ? data.results : []; + + const results = items.map((item: any, idx: number) => + makeResult( + "ollama-search", + { + title: item?.title, + url: item?.url, + snippet: item?.content || "", + full_text: item?.content, + text_format: "text", + }, + idx, + now + ) + ); + + return { results, totalResults: results.length }; +} + function normalizeResponse( providerId: string, data: any, @@ -899,6 +946,7 @@ function normalizeResponse( if (providerId === "searchapi-search") return normalizeSearchApiResponse(data, query, searchType); if (providerId === "youcom-search") return normalizeYouComResponse(data, query, searchType); if (providerId === "searxng-search") return normalizeSearxngResponse(data, query, searchType); + if (providerId === "ollama-search") return normalizeOllamaResponse(data, query, searchType); return { results: [], totalResults: null }; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 57a95eee6d..505eb09522 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2360,6 +2360,14 @@ const SEARCH_VALIDATOR_CONFIGS: Record< }, }; }, + "ollama-search": (apiKey) => ({ + url: "https://ollama.com/api/web_search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: "test", max_results: 1 }), + }, + }), }; // See open-sse/executors/muse-spark-web.ts for the rationale: Meta migrated diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index ffa88a1cfe..1f06a86ebe 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1776,6 +1776,16 @@ export const SEARCH_PROVIDERS = { authHint: "API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.", }, + "ollama-search": { + id: "ollama-search", + alias: "ollama-search", + name: "Ollama Search", + icon: "search", + color: "#58A6FF", + textIcon: "OS", + website: "https://ollama.com/settings/api-keys", + authHint: "Same API key as Ollama Cloud (from ollama.com/settings/api-keys)", + }, }; // Audio Only Providers diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 234e79b48b..0865b35f61 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1803,6 +1803,7 @@ export const v1SearchSchema = z "tavily-search", "google-pse-search", "linkup-search", + "ollama-search", "searchapi-search", "youcom-search", "searxng-search", diff --git a/tests/unit/search-handler-extended.test.ts b/tests/unit/search-handler-extended.test.ts index c295560179..4402b3c205 100644 --- a/tests/unit/search-handler-extended.test.ts +++ b/tests/unit/search-handler-extended.test.ts @@ -6,7 +6,9 @@ import { join } from "node:path"; process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-search-")); -const { handleSearch } = await import("../../open-sse/handlers/search.ts"); +const { handleSearch } = await import( + "../../open-sse/handlers/search.ts" +); test("handleSearch builds Serper web requests and normalizes organic results", async () => { const originalFetch = globalThis.fetch; @@ -709,3 +711,156 @@ test("handleSearch does not fail over on non-retriable upstream errors", async ( globalThis.fetch = originalFetch; } }); + +// ─── Ollama Search Tests ────────────────────────────────── + +test("handleSearch builds Ollama POST request with bearer auth", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + headers: init.headers, + body: JSON.parse(String(init.body || "{}")), + }; + + return new Response( + JSON.stringify({ + results: [ + { title: "Ollama result", url: "https://ollama.example.com", content: "Test content" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleSearch({ + query: "latest AI news", + provider: "ollama-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "ollama-token-123" }, + log: null, + }); + + assert.equal(captured.url, "https://ollama.com/api/web_search"); + assert.equal(captured.headers["Content-Type"], "application/json"); + assert.equal(captured.headers.Authorization, "Bearer ollama-token-123"); + assert.deepEqual(captured.body, { query: "latest AI news", max_results: 5 }); + assert.equal(result.success, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleSearch normalizes Ollama response fields and full_text content", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + results: [ + { + title: "Ollama Web Search", + url: "https://ollama.com/blog/web-search", + content: "Ollama now supports native web search capabilities", + }, + { + title: "Getting Started", + url: "https://ollama.com/docs", + content: "Learn how to use Ollama for LLM inference", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleSearch({ + query: "ollama query", + provider: "ollama-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "ollama-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(result.data.results.length, 2); + assert.equal(result.data.metrics.total_results_available, 2); + + assert.equal(result.data.results[0].title, "Ollama Web Search"); + assert.equal(result.data.results[0].url, "https://ollama.com/blog/web-search"); + assert.equal(result.data.results[0].snippet, "Ollama now supports native web search capabilities"); + assert.equal(result.data.results[0].content.text, "Ollama now supports native web search capabilities"); + assert.equal(result.data.results[0].content.format, "text"); + assert.equal(result.data.results[0].position, 1); + assert.equal(result.data.results[0].citation.provider, "ollama-search"); + assert.equal(result.data.results[0].citation.rank, 1); + + assert.equal(result.data.results[1].title, "Getting Started"); + assert.equal(result.data.results[1].position, 2); + assert.equal(result.data.results[1].citation.rank, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleSearch handles empty Ollama results array", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ results: [] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleSearch({ + query: "empty query", + provider: "ollama-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "ollama-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(result.data.results.length, 0); + assert.equal(result.data.metrics.total_results_available, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleSearch handles Ollama response with missing results field", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ unrelated: "data" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleSearch({ + query: "some query", + provider: "ollama-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "ollama-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(result.data.results.length, 0); + assert.equal(result.data.metrics.total_results_available, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/search-registry.test.ts b/tests/unit/search-registry.test.ts index aa77867a0e..f7900d1f52 100644 --- a/tests/unit/search-registry.test.ts +++ b/tests/unit/search-registry.test.ts @@ -30,7 +30,8 @@ test("SEARCH_PROVIDERS has all 10 providers", () => { assert.ok(SEARCH_PROVIDERS["searchapi-search"], "searchapi should exist"); assert.ok(SEARCH_PROVIDERS["youcom-search"], "youcom should exist"); assert.ok(SEARCH_PROVIDERS["searxng-search"], "searxng should exist"); - assert.equal(Object.keys(SEARCH_PROVIDERS).length, 10); + assert.ok(SEARCH_PROVIDERS["ollama-search"], "ollama-search should exist"); + assert.equal(Object.keys(SEARCH_PROVIDERS).length, 11); }); test("serper-search config is correct", () => { @@ -63,6 +64,17 @@ test("perplexity-search config is correct", () => { assert.deepEqual(p.searchTypes, ["web"]); }); +test("ollama-search config is correct", () => { + const o = SEARCH_PROVIDERS["ollama-search"]; + assert.equal(o.id, "ollama-search"); + assert.equal(o.baseUrl, "https://ollama.com/api/web_search"); + assert.equal(o.method, "POST"); + assert.equal(o.authType, "apikey"); + assert.equal(o.authHeader, "bearer"); + assert.equal(o.maxMaxResults, 10); + assert.deepEqual(o.searchTypes, ["web"]); +}); + test("getSearchProvider returns config for valid ID", () => { const config = getSearchProvider("serper-search"); assert.ok(config); @@ -130,7 +142,7 @@ test("searxng-search config is correct", () => { test("getAllSearchProviders returns flat list", () => { const all = getAllSearchProviders(); - assert.equal(all.length, 10); + assert.equal(all.length, 11); assert.ok(all.some((p) => p.id === "serper-search")); assert.ok(all.some((p) => p.id === "brave-search")); assert.ok(all.some((p) => p.id === "perplexity-search")); @@ -141,6 +153,7 @@ test("getAllSearchProviders returns flat list", () => { assert.ok(all.some((p) => p.id === "searchapi-search")); assert.ok(all.some((p) => p.id === "youcom-search")); assert.ok(all.some((p) => p.id === "searxng-search")); + assert.ok(all.some((p) => p.id === "ollama-search")); // Each entry should have id, name, searchTypes for (const p of all) { assert.ok(p.id); @@ -337,6 +350,7 @@ test("v1SearchSchema accepts new search providers", async () => { "searchapi-search", "youcom-search", "searxng-search", + "ollama-search", ] as const; for (const provider of providers) { diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 36ca742fc7..f948e072e8 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -45,14 +45,14 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("v1 search GET lists all 10 search providers", async () => { +test("v1 search GET lists all 11 search providers", async () => { const response = await searchRoute.GET(); const body = (await response.json()) as any; const ids = body.data.map((item: { id: string }) => item.id); assert.equal(response.status, 200); assert.equal(body.object, "list"); - assert.equal(body.data.length, 10); + assert.equal(body.data.length, 11); assert.deepEqual(ids, [ "serper-search", "brave-search", @@ -64,6 +64,7 @@ test("v1 search GET lists all 10 search providers", async () => { "searchapi-search", "youcom-search", "searxng-search", + "ollama-search", ]); });