feat(search): add Ollama Search as a web search provider (#2176)

Integrated into release/v3.8.0 — adds Ollama Search as a web search provider.
This commit is contained in:
Andrew Munsell
2026-05-11 17:54:34 -07:00
committed by GitHub
parent ff730b372e
commit ed1554b7a1
8 changed files with 261 additions and 7 deletions

View File

@@ -186,6 +186,22 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
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<string, SearchProviderConfig> = {
*/
export const SEARCH_CREDENTIAL_FALLBACKS: Record<string, string> = {
"perplexity-search": "perplexity",
"ollama-search": "ollama-cloud",
};
/**

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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

View File

@@ -1803,6 +1803,7 @@ export const v1SearchSchema = z
"tavily-search",
"google-pse-search",
"linkup-search",
"ollama-search",
"searchapi-search",
"youcom-search",
"searxng-search",

View File

@@ -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;
}
});

View File

@@ -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) {

View File

@@ -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",
]);
});