mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1803,6 +1803,7 @@ export const v1SearchSchema = z
|
||||
"tavily-search",
|
||||
"google-pse-search",
|
||||
"linkup-search",
|
||||
"ollama-search",
|
||||
"searchapi-search",
|
||||
"youcom-search",
|
||||
"searxng-search",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user