mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
1441 lines
43 KiB
TypeScript
1441 lines
43 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
/**
|
|
* Search Handler
|
|
*
|
|
* Handles POST /v1/search requests.
|
|
* 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, ollama-search, zai-search
|
|
*
|
|
* Request format:
|
|
* {
|
|
* "query": "search query",
|
|
* "provider": "serper-search" | "brave-search" | ... // optional, auto-selects cheapest
|
|
* "max_results": 5,
|
|
* "search_type": "web" | "news"
|
|
* }
|
|
*/
|
|
|
|
import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts";
|
|
import { saveCallLog } from "@/lib/usageDb";
|
|
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { z } from "zod";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
|
|
export interface SearchResult {
|
|
title: string;
|
|
url: string;
|
|
display_url?: string;
|
|
snippet: string;
|
|
position: number;
|
|
score: number | null;
|
|
published_at: string | null;
|
|
favicon_url: string | null;
|
|
content: { format: string; text: string; length: number } | null;
|
|
metadata: {
|
|
author: string | null;
|
|
language: string | null;
|
|
source_type: string | null;
|
|
image_url: string | null;
|
|
} | null;
|
|
citation: {
|
|
provider: string;
|
|
retrieved_at: string;
|
|
rank: number;
|
|
};
|
|
provider_raw: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface SearchResponse {
|
|
provider: string;
|
|
query: string;
|
|
results: SearchResult[];
|
|
answer: { source: string; text: string | null; model: string | null } | null;
|
|
usage: { queries_used: number; search_cost_usd: number; llm_tokens?: number };
|
|
metrics: {
|
|
response_time_ms: number;
|
|
upstream_latency_ms: number;
|
|
gateway_latency_ms?: number;
|
|
total_results_available: number | null;
|
|
};
|
|
errors: Array<{ provider: string; code: string; message: string }>;
|
|
}
|
|
|
|
interface SearchHandlerResult {
|
|
success: boolean;
|
|
status?: number;
|
|
error?: string;
|
|
data?: SearchResponse;
|
|
}
|
|
|
|
interface SearchHandlerOptions {
|
|
query: string;
|
|
provider: string;
|
|
maxResults: number;
|
|
searchType: string;
|
|
country?: string;
|
|
language?: string;
|
|
timeRange?: string;
|
|
offset?: number;
|
|
domainFilter?: string[];
|
|
contentOptions?: {
|
|
snippet?: boolean;
|
|
full_page?: boolean;
|
|
format?: string;
|
|
max_characters?: number;
|
|
};
|
|
strictFilters?: boolean;
|
|
providerOptions?: Record<string, unknown>;
|
|
credentials: Record<string, any>;
|
|
alternateProvider?: string;
|
|
alternateCredentials?: Record<string, any> | null;
|
|
log?: any;
|
|
}
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
|
|
const GLOBAL_TIMEOUT_MS = 15_000;
|
|
|
|
// Non-retriable HTTP status codes — fail immediately, don't try alternate
|
|
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
|
|
|
// ── Input Sanitization ──────────────────────────────────────────────────
|
|
|
|
// Control characters that should never appear in search queries
|
|
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
|
|
function sanitizeQuery(query: string): { clean: string; error?: string } {
|
|
if (CONTROL_CHAR_RE.test(query)) {
|
|
return { clean: "", error: "Query contains invalid control characters" };
|
|
}
|
|
const clean = query.normalize("NFKC").trim().replace(/\s+/g, " ");
|
|
if (clean.length === 0) {
|
|
return { clean: "", error: "Query is empty after normalization" };
|
|
}
|
|
return { clean };
|
|
}
|
|
|
|
// ── Response Normalizers ────────────────────────────────────────────────
|
|
|
|
function makeResult(
|
|
providerId: string,
|
|
item: {
|
|
title?: string;
|
|
url?: string;
|
|
snippet?: string;
|
|
score?: number;
|
|
published_at?: string;
|
|
favicon_url?: string;
|
|
author?: string;
|
|
source_type?: string;
|
|
image_url?: string;
|
|
full_text?: string;
|
|
text_format?: string;
|
|
},
|
|
idx: number,
|
|
now: string
|
|
): SearchResult {
|
|
const url = item.url || "";
|
|
return {
|
|
title: item.title || "",
|
|
url,
|
|
display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined,
|
|
snippet: item.snippet || "",
|
|
position: idx + 1,
|
|
score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null,
|
|
published_at: item.published_at || null,
|
|
favicon_url: item.favicon_url || null,
|
|
content: item.full_text
|
|
? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length }
|
|
: null,
|
|
metadata: {
|
|
author: item.author || null,
|
|
language: null,
|
|
source_type: item.source_type || null,
|
|
image_url: item.image_url || null,
|
|
},
|
|
citation: { provider: providerId, retrieved_at: now, rank: idx + 1 },
|
|
provider_raw: null,
|
|
};
|
|
}
|
|
|
|
function normalizeSerperResponse(
|
|
data: any,
|
|
_query: string,
|
|
searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = searchType === "news" ? data.news : data.organic;
|
|
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"serper-search",
|
|
{
|
|
title: item.title,
|
|
url: item.link,
|
|
snippet: item.snippet || item.description,
|
|
published_at: item.date,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
return {
|
|
results,
|
|
totalResults:
|
|
typeof data.searchParameters?.totalResults === "number"
|
|
? data.searchParameters.totalResults
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function normalizeBraveResponse(
|
|
data: any,
|
|
_query: string,
|
|
searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
// Brave news endpoint returns { results: [...] } directly,
|
|
// while web endpoint returns { web: { results: [...] } }
|
|
const container = searchType === "news" ? data.news || data : data.web;
|
|
const items = container?.results;
|
|
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"brave-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet: item.description,
|
|
published_at: item.page_age || item.age,
|
|
favicon_url: item.meta_url?.favicon || item.favicon,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
return { results, totalResults: container?.totalCount ?? null };
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function parseDomainFilter(domainFilter?: string[]): {
|
|
includes: string[];
|
|
excludes: string[];
|
|
} {
|
|
if (!domainFilter?.length) return { includes: [], excludes: [] };
|
|
const includes = domainFilter.filter((d) => !d.startsWith("-"));
|
|
const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1));
|
|
return { includes, excludes };
|
|
}
|
|
|
|
function getProviderSettingString(
|
|
params: Pick<SearchRequestParams, "providerOptions" | "providerSpecificData">,
|
|
key: string
|
|
): string | undefined {
|
|
const fromOptions = params.providerOptions?.[key];
|
|
if (typeof fromOptions === "string" && fromOptions.trim().length > 0) {
|
|
return fromOptions.trim();
|
|
}
|
|
|
|
const fromProviderData = params.providerSpecificData?.[key];
|
|
if (typeof fromProviderData === "string" && fromProviderData.trim().length > 0) {
|
|
return fromProviderData.trim();
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
|
|
const override = getProviderSettingString(params, "baseUrl");
|
|
return (override || config.baseUrl).replace(/\/+$/, "");
|
|
}
|
|
|
|
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
|
|
if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined;
|
|
return Math.floor(offset / maxResults) + 1;
|
|
}
|
|
|
|
// ── Provider Request Builders ───────────────────────────────────────────
|
|
|
|
interface SearchRequestParams {
|
|
query: string;
|
|
searchType: string;
|
|
maxResults: number;
|
|
token?: string;
|
|
country?: string;
|
|
language?: string;
|
|
timeRange?: string;
|
|
offset?: number;
|
|
domainFilter?: string[];
|
|
contentOptions?: {
|
|
snippet?: boolean;
|
|
full_page?: boolean;
|
|
format?: string;
|
|
max_characters?: number;
|
|
};
|
|
providerOptions?: Record<string, unknown>;
|
|
providerSpecificData?: Record<string, unknown>;
|
|
}
|
|
|
|
function buildSerperRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const endpoint = params.searchType === "news" ? "/news" : "/search";
|
|
const body: Record<string, unknown> = { q: params.query, num: params.maxResults };
|
|
if (params.country) body.gl = params.country.toLowerCase();
|
|
if (params.language) body.hl = params.language;
|
|
return {
|
|
url: `${config.baseUrl}${endpoint}`,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-API-Key": params.token },
|
|
body: JSON.stringify(body),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildBraveRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const endpoint = params.searchType === "news" ? "/news/search" : "/web/search";
|
|
const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) });
|
|
if (params.country) qp.set("country", params.country);
|
|
if (params.language) qp.set("search_lang", params.language);
|
|
return {
|
|
url: `${config.baseUrl}${endpoint}?${qp}`,
|
|
init: {
|
|
method: "GET",
|
|
headers: { Accept: "application/json", "X-Subscription-Token": params.token },
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildPerplexityRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const body: Record<string, unknown> = { query: params.query, max_results: params.maxResults };
|
|
if (params.country) body.country = params.country;
|
|
if (params.language) body.search_language_filter = [params.language];
|
|
if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter;
|
|
return {
|
|
url: config.baseUrl,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` },
|
|
body: JSON.stringify(body),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildExaRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
|
const body: Record<string, unknown> = {
|
|
query: params.query,
|
|
numResults: params.maxResults,
|
|
type: "auto",
|
|
text: true,
|
|
highlights: true,
|
|
};
|
|
if (includes.length) body.includeDomains = includes;
|
|
if (excludes.length) body.excludeDomains = excludes;
|
|
if (params.searchType === "news") body.category = "news";
|
|
return {
|
|
url: config.baseUrl,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "x-api-key": params.token },
|
|
body: JSON.stringify(body),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildTavilyRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
|
const body: Record<string, unknown> = {
|
|
query: params.query,
|
|
max_results: params.maxResults,
|
|
topic: params.searchType === "news" ? "news" : "general",
|
|
};
|
|
if (includes.length) body.include_domains = includes;
|
|
if (excludes.length) body.exclude_domains = excludes;
|
|
if (params.country) body.country = params.country;
|
|
return {
|
|
url: config.baseUrl,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` },
|
|
body: JSON.stringify(body),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildGooglePseRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const apiKey = params.token;
|
|
const cx = getProviderSettingString(params, "cx");
|
|
if (!apiKey || !cx) {
|
|
throw new Error("Google Programmable Search requires both apiKey and cx");
|
|
}
|
|
|
|
const qp = new URLSearchParams({
|
|
key: apiKey,
|
|
cx,
|
|
q: params.query,
|
|
num: String(Math.min(params.maxResults, 10)),
|
|
});
|
|
|
|
if (params.country) qp.set("gl", params.country.toLowerCase());
|
|
if (params.language) qp.set("hl", params.language);
|
|
if (params.timeRange && params.timeRange !== "any") {
|
|
const dateRestrictMap: Record<string, string> = {
|
|
day: "d1",
|
|
week: "w1",
|
|
month: "m1",
|
|
year: "y1",
|
|
};
|
|
const dateRestrict = dateRestrictMap[params.timeRange];
|
|
if (dateRestrict) qp.set("dateRestrict", dateRestrict);
|
|
}
|
|
if (typeof params.offset === "number" && params.offset > 0) {
|
|
qp.set("start", String(Math.min(params.offset + 1, 91)));
|
|
}
|
|
|
|
return {
|
|
url: `${resolveSearchBaseUrl(config, params)}?${qp}`,
|
|
init: {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildLinkupRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const apiKey = params.token;
|
|
if (!apiKey) {
|
|
throw new Error("Linkup Search requires an API key");
|
|
}
|
|
|
|
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
|
const requestedDepth = getProviderSettingString(params, "depth");
|
|
const depth =
|
|
requestedDepth && ["fast", "standard", "deep"].includes(requestedDepth)
|
|
? requestedDepth
|
|
: "standard";
|
|
|
|
const body: Record<string, unknown> = {
|
|
q: params.query,
|
|
depth,
|
|
outputType: "searchResults",
|
|
maxResults: params.maxResults,
|
|
};
|
|
|
|
if (includes.length) body.includeDomains = includes;
|
|
if (excludes.length) body.excludeDomains = excludes;
|
|
if (params.timeRange && params.timeRange !== "any") {
|
|
const today = new Date();
|
|
const toDate = today.toISOString().slice(0, 10);
|
|
const from = new Date(today);
|
|
if (params.timeRange === "day") from.setUTCDate(from.getUTCDate() - 1);
|
|
if (params.timeRange === "week") from.setUTCDate(from.getUTCDate() - 7);
|
|
if (params.timeRange === "month") from.setUTCMonth(from.getUTCMonth() - 1);
|
|
if (params.timeRange === "year") from.setUTCFullYear(from.getUTCFullYear() - 1);
|
|
body.fromDate = from.toISOString().slice(0, 10);
|
|
body.toDate = toDate;
|
|
}
|
|
|
|
return {
|
|
url: resolveSearchBaseUrl(config, params),
|
|
init: {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildSearchApiRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const apiKey = params.token;
|
|
if (!apiKey) {
|
|
throw new Error("SearchAPI requires an API key");
|
|
}
|
|
|
|
const qp = new URLSearchParams({
|
|
engine: params.searchType === "news" ? "google_news" : "google",
|
|
q: params.query,
|
|
api_key: apiKey,
|
|
});
|
|
|
|
if (params.country) qp.set("gl", params.country.toLowerCase());
|
|
if (params.language) qp.set("hl", params.language);
|
|
|
|
const page = toSearchPageNumber(params.offset, params.maxResults);
|
|
if (page) qp.set("page", String(page));
|
|
|
|
return {
|
|
url: `${resolveSearchBaseUrl(config, params)}?${qp}`,
|
|
init: {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildYouComRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const apiKey = params.token;
|
|
if (!apiKey) {
|
|
throw new Error("You.com Search requires an API key");
|
|
}
|
|
|
|
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
|
const qp = new URLSearchParams({
|
|
query: params.query,
|
|
count: String(Math.min(params.maxResults, 100)),
|
|
});
|
|
|
|
if (params.timeRange && params.timeRange !== "any") {
|
|
qp.set("freshness", params.timeRange);
|
|
}
|
|
if (typeof params.offset === "number" && params.offset > 0 && params.maxResults > 0) {
|
|
qp.set("offset", String(Math.min(Math.floor(params.offset / params.maxResults), 9)));
|
|
}
|
|
if (params.country) qp.set("country", params.country);
|
|
if (params.language) qp.set("language", params.language);
|
|
if (includes.length) qp.set("include_domains", includes.join(","));
|
|
if (excludes.length) qp.set("exclude_domains", excludes.join(","));
|
|
|
|
if (params.contentOptions?.full_page) {
|
|
qp.set("livecrawl", params.searchType === "news" ? "news" : "web");
|
|
qp.append(
|
|
"livecrawl_formats",
|
|
params.contentOptions.format === "markdown" ? "markdown" : "html"
|
|
);
|
|
}
|
|
|
|
return {
|
|
url: `${resolveSearchBaseUrl(config, params)}?${qp}`,
|
|
init: {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"X-API-Key": apiKey,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildSearxngRequest(
|
|
config: SearchProviderConfig,
|
|
params: SearchRequestParams
|
|
): { url: string; init: RequestInit } {
|
|
const baseUrl = resolveSearchBaseUrl(config, params);
|
|
const url = baseUrl.endsWith("/search") ? baseUrl : `${baseUrl}/search`;
|
|
const qp = new URLSearchParams({
|
|
q: params.query,
|
|
format: "json",
|
|
categories: params.searchType === "news" ? "news" : "general",
|
|
});
|
|
|
|
if (params.language) qp.set("language", params.language);
|
|
if (params.timeRange && params.timeRange !== "any") qp.set("time_range", params.timeRange);
|
|
|
|
const page = toSearchPageNumber(params.offset, params.maxResults);
|
|
if (page) qp.set("pageno", String(page));
|
|
|
|
const headers: Record<string, string> = { Accept: "application/json" };
|
|
if (params.token) {
|
|
headers["Authorization"] = `Bearer ${params.token}`;
|
|
}
|
|
|
|
return {
|
|
url: `${url}?${qp}`,
|
|
init: {
|
|
method: "GET",
|
|
headers,
|
|
},
|
|
};
|
|
}
|
|
|
|
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
|
|
): { url: string; init: RequestInit } {
|
|
if (config.id === "serper-search") return buildSerperRequest(config, params);
|
|
if (config.id === "brave-search") return buildBraveRequest(config, params);
|
|
if (config.id === "perplexity-search") return buildPerplexityRequest(config, params);
|
|
if (config.id === "exa-search") return buildExaRequest(config, params);
|
|
if (config.id === "tavily-search") return buildTavilyRequest(config, params);
|
|
if (config.id === "google-pse-search") return buildGooglePseRequest(config, params);
|
|
if (config.id === "linkup-search") return buildLinkupRequest(config, params);
|
|
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),
|
|
init: {
|
|
method: config.method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
query: params.query,
|
|
max_results: params.maxResults,
|
|
search_type: params.searchType,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizePerplexityResponse(
|
|
data: any,
|
|
_query: string,
|
|
_searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = data.results;
|
|
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"perplexity-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet: item.snippet,
|
|
published_at: item.date || item.last_updated,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
return { results, totalResults: results.length };
|
|
}
|
|
|
|
function normalizeExaResponse(
|
|
data: any,
|
|
_query: string,
|
|
_searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = data.results;
|
|
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"exa-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "",
|
|
score: item.score,
|
|
published_at: item.publishedDate,
|
|
favicon_url: item.favicon,
|
|
author: item.author,
|
|
image_url: item.image,
|
|
full_text: item.text,
|
|
text_format: "text",
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
return { results, totalResults: results.length };
|
|
}
|
|
|
|
function normalizeTavilyResponse(
|
|
data: any,
|
|
_query: string,
|
|
_searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = data.results;
|
|
if (!Array.isArray(items)) return { results: [], totalResults: null };
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"tavily-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet: item.content || "",
|
|
score: item.score,
|
|
published_at: item.published_date,
|
|
full_text: item.raw_content,
|
|
text_format: "text",
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
return { results, totalResults: results.length };
|
|
}
|
|
|
|
function normalizeGooglePseResponse(
|
|
data: any,
|
|
_query: string,
|
|
_searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"google-pse-search",
|
|
{
|
|
title: item.title,
|
|
url: item.link,
|
|
snippet: item.snippet,
|
|
image_url:
|
|
item.pagemap?.cse_image?.[0]?.src ||
|
|
item.pagemap?.cse_thumbnail?.[0]?.src ||
|
|
item.pagemap?.metatags?.[0]?.["og:image"],
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
const totalResultsRaw =
|
|
data.searchInformation?.totalResults ?? data.queries?.request?.[0]?.totalResults ?? null;
|
|
const totalResults =
|
|
typeof totalResultsRaw === "string" ? Number(totalResultsRaw) : totalResultsRaw;
|
|
|
|
return {
|
|
results,
|
|
totalResults: Number.isFinite(totalResults) ? totalResults : null,
|
|
};
|
|
}
|
|
|
|
function normalizeLinkupResponse(
|
|
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(
|
|
"linkup-search",
|
|
{
|
|
title: item.name || item.title,
|
|
url: item.url,
|
|
snippet: item.content || item.snippet || "",
|
|
source_type: item.type || "web",
|
|
image_url: item.image_url || item.imageUrl || null,
|
|
full_text: item.content,
|
|
text_format: "text",
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
return { results, totalResults: results.length };
|
|
}
|
|
|
|
function normalizeSearchApiResponse(
|
|
data: any,
|
|
_query: string,
|
|
_searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const items = Array.isArray(data.organic_results)
|
|
? data.organic_results
|
|
: Array.isArray(data.top_stories)
|
|
? data.top_stories
|
|
: [];
|
|
|
|
const results = items.map((item: any, idx: number) =>
|
|
makeResult(
|
|
"searchapi-search",
|
|
{
|
|
title: item.title,
|
|
url: item.link,
|
|
snippet: item.snippet || item.description || "",
|
|
published_at: item.date || item.published_at,
|
|
favicon_url: item.favicon,
|
|
author: item.source || null,
|
|
image_url: item.thumbnail || null,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
const totalResults =
|
|
typeof data.search_information?.total_results === "number"
|
|
? data.search_information.total_results
|
|
: typeof data.search_information?.total_results === "string"
|
|
? Number(data.search_information.total_results)
|
|
: null;
|
|
|
|
return {
|
|
results,
|
|
totalResults: Number.isFinite(totalResults) ? totalResults : results.length,
|
|
};
|
|
}
|
|
|
|
function normalizeYouComResponse(
|
|
data: any,
|
|
_query: string,
|
|
searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
const now = new Date().toISOString();
|
|
const resultsContainer =
|
|
data?.results && typeof data.results === "object" ? data.results : undefined;
|
|
const section =
|
|
searchType === "news" ? resultsContainer?.news || [] : resultsContainer?.web || [];
|
|
const items = Array.isArray(section) ? section : [];
|
|
|
|
const results = items.map((item: any, idx: number) => {
|
|
const firstSnippet = Array.isArray(item.snippets)
|
|
? item.snippets.find((value: unknown) => typeof value === "string")
|
|
: null;
|
|
const livecrawlText =
|
|
typeof item.markdown === "string"
|
|
? item.markdown
|
|
: typeof item.html === "string"
|
|
? item.html
|
|
: undefined;
|
|
const livecrawlFormat = typeof item.markdown === "string" ? "markdown" : "html";
|
|
|
|
return makeResult(
|
|
"youcom-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet:
|
|
typeof firstSnippet === "string"
|
|
? firstSnippet
|
|
: typeof item.description === "string"
|
|
? item.description
|
|
: "",
|
|
published_at: item.page_age,
|
|
favicon_url: item.favicon_url,
|
|
image_url: item.thumbnail_url,
|
|
source_type: searchType,
|
|
full_text: livecrawlText,
|
|
text_format: livecrawlText ? livecrawlFormat : undefined,
|
|
},
|
|
idx,
|
|
now
|
|
);
|
|
});
|
|
|
|
return { results, totalResults: results.length };
|
|
}
|
|
|
|
function normalizeSearxngResponse(
|
|
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(
|
|
"searxng-search",
|
|
{
|
|
title: item.title,
|
|
url: item.url,
|
|
snippet: item.content || item.snippet || "",
|
|
published_at: item.publishedDate || item.published_date || null,
|
|
source_type: Array.isArray(item.engines)
|
|
? item.engines.join(", ")
|
|
: item.engine || item.category || null,
|
|
image_url: item.thumbnail || item.img_src || null,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
|
|
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 };
|
|
}
|
|
|
|
// ── Z.AI Coding Plan Search MCP Execution ───────────────────────────
|
|
|
|
// Schema for the Z.AI MCP web_search_prime tool result. Z.AI double-encodes
|
|
// the results array as a JSON string inside the MCP text content, so we
|
|
// safely unwrap it with a typed schema instead of `JSON.parse(parsed)`.
|
|
const ZaiSearchItemSchema = z
|
|
.object({
|
|
title: z.string().optional(),
|
|
link: z.string().optional(),
|
|
content: z.string().optional(),
|
|
publish_date: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
media: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
type ZaiSearchItem = z.infer<typeof ZaiSearchItemSchema>;
|
|
|
|
const ZaiSearchResultsSchema = z.array(ZaiSearchItemSchema);
|
|
|
|
/**
|
|
* Unwrap the double-encoded JSON from a Z.AI MCP web_search_prime response.
|
|
*
|
|
* Quirk: the MCP server returns a text content block whose body is a JSON
|
|
* string. That JSON string, once parsed, is itself another JSON string
|
|
* containing the actual results array. We try a single parse first
|
|
* (in case the upstream behavior ever changes), then a nested parse.
|
|
* Both paths are validated through `ZaiSearchResultsSchema` so any shape
|
|
* regression upstream lands in our error path instead of corrupting results.
|
|
*/
|
|
function unwrapZaiContent(rawText: string): ZaiSearchItem[] | null {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(rawText);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
// Direct array path (defensive, in case Z.AI stops double-encoding).
|
|
const direct = ZaiSearchResultsSchema.safeParse(parsed);
|
|
if (direct.success) return direct.data;
|
|
|
|
// Documented Z.AI behavior: parsed is a JSON string of the results array.
|
|
if (typeof parsed !== "string") return null;
|
|
let inner: unknown;
|
|
try {
|
|
inner = JSON.parse(parsed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const validated = ZaiSearchResultsSchema.safeParse(inner);
|
|
return validated.success ? validated.data : null;
|
|
}
|
|
|
|
async function zaiSearchExecute(params: {
|
|
config: SearchProviderConfig;
|
|
query: string;
|
|
token: string;
|
|
params: SearchRequestParams;
|
|
signal?: AbortSignal;
|
|
}): Promise<{ results: SearchResult[]; totalResults: number | null }> {
|
|
const baseUrl = resolveSearchBaseUrl(params.config, params.params);
|
|
const transport = new StreamableHTTPClientTransport(new URL(baseUrl), {
|
|
fetch: safeOutboundFetch,
|
|
requestInit: {
|
|
headers: {
|
|
Authorization: `Bearer ${params.token}`,
|
|
},
|
|
},
|
|
});
|
|
|
|
const client = new Client({ name: "omniroute-search", version: "1.0" }, { capabilities: {} });
|
|
|
|
const { signal } = params;
|
|
|
|
let abortHandler: (() => void) | undefined;
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
throw new DOMException("The operation was aborted", "AbortError");
|
|
}
|
|
abortHandler = () => {
|
|
client.close().catch(() => {});
|
|
};
|
|
signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
try {
|
|
await client.connect(transport);
|
|
|
|
if (signal?.aborted) {
|
|
throw new DOMException("The operation was aborted", "AbortError");
|
|
}
|
|
|
|
const args: Record<string, unknown> = {
|
|
search_query: params.query,
|
|
};
|
|
|
|
const { includes } = parseDomainFilter(params.params.domainFilter);
|
|
if (includes.length > 0) {
|
|
args.search_domain_filter = includes.join(",");
|
|
}
|
|
|
|
const toolResult = await client.callTool({
|
|
name: "web_search_prime",
|
|
arguments: args,
|
|
});
|
|
|
|
const rawContent = Array.isArray(toolResult.content) ? toolResult.content : [];
|
|
const rawText = rawContent
|
|
.filter((c: any) => c?.type === "text")
|
|
.map((c: any) => (typeof c?.text === "string" ? c.text : ""))
|
|
.join("\n");
|
|
|
|
if (!rawText.trim()) {
|
|
return { results: [], totalResults: null };
|
|
}
|
|
|
|
const items = unwrapZaiContent(rawText);
|
|
if (!items) {
|
|
return { results: [], totalResults: null };
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const results = items.map((item, idx) =>
|
|
makeResult(
|
|
"zai-search",
|
|
{
|
|
title: item.title,
|
|
url: item.link,
|
|
snippet: item.content || "",
|
|
published_at: item.publish_date,
|
|
favicon_url: item.icon,
|
|
source_type: item.media,
|
|
},
|
|
idx,
|
|
now
|
|
)
|
|
);
|
|
return { results, totalResults: results.length };
|
|
} finally {
|
|
if (abortHandler && signal) {
|
|
signal.removeEventListener("abort", abortHandler);
|
|
}
|
|
await client.close();
|
|
}
|
|
}
|
|
|
|
async function tryZaiMCPProvider(
|
|
config: SearchProviderConfig,
|
|
params: Omit<SearchRequestParams, "token">,
|
|
token: string,
|
|
providerSpecificData: Record<string, unknown> | undefined,
|
|
startTime: number,
|
|
globalStartTime: number,
|
|
log?: any
|
|
): Promise<SearchHandlerResult> {
|
|
const { query, searchType, maxResults } = params;
|
|
|
|
const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime);
|
|
const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000));
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
try {
|
|
const normalized = await zaiSearchExecute({
|
|
config,
|
|
query,
|
|
token,
|
|
params: { ...params, token, providerSpecificData },
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
|
|
const results = normalized.results.slice(0, maxResults);
|
|
const duration = Date.now() - startTime;
|
|
|
|
saveCallLog({
|
|
method: config.method,
|
|
path: "/v1/search",
|
|
status: 200,
|
|
model: config.id,
|
|
provider: config.id,
|
|
duration,
|
|
requestType: "search",
|
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
|
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
|
responseBody: { results_count: results.length, cached: false },
|
|
}).catch(() => {
|
|
/* non-critical — logging must not block search response */
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
provider: config.id,
|
|
query,
|
|
results,
|
|
answer: null,
|
|
usage: { queries_used: 1, search_cost_usd: config.costPerQuery },
|
|
metrics: {
|
|
response_time_ms: duration,
|
|
upstream_latency_ms: duration,
|
|
total_results_available: normalized.totalResults,
|
|
},
|
|
errors: [],
|
|
},
|
|
};
|
|
} catch (err: any) {
|
|
clearTimeout(timer);
|
|
|
|
const isTimeout = err.name === "AbortError";
|
|
if (log) {
|
|
log.error("SEARCH", `${config.id} MCP ${isTimeout ? "timeout" : "error"}: ${err.message}`);
|
|
}
|
|
|
|
saveCallLog({
|
|
method: config.method,
|
|
path: "/v1/search",
|
|
status: isTimeout ? 504 : 502,
|
|
model: config.id,
|
|
provider: config.id,
|
|
duration: Date.now() - startTime,
|
|
requestType: "search",
|
|
error: err.message,
|
|
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
|
}).catch(() => {
|
|
/* non-critical — logging must not block search response */
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
status: isTimeout ? 504 : 502,
|
|
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizeResponse(
|
|
providerId: string,
|
|
data: any,
|
|
query: string,
|
|
searchType: string
|
|
): { results: SearchResult[]; totalResults: number | null } {
|
|
if (providerId === "serper-search") return normalizeSerperResponse(data, query, searchType);
|
|
if (providerId === "brave-search") return normalizeBraveResponse(data, query, searchType);
|
|
if (providerId === "perplexity-search")
|
|
return normalizePerplexityResponse(data, query, searchType);
|
|
if (providerId === "exa-search") return normalizeExaResponse(data, query, searchType);
|
|
if (providerId === "tavily-search") return normalizeTavilyResponse(data, query, searchType);
|
|
if (providerId === "google-pse-search")
|
|
return normalizeGooglePseResponse(data, query, searchType);
|
|
if (providerId === "linkup-search") return normalizeLinkupResponse(data, query, searchType);
|
|
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 };
|
|
}
|
|
|
|
// ── Main Handler ────────────────────────────────────────────────────────
|
|
|
|
export async function handleSearch(options: SearchHandlerOptions): Promise<SearchHandlerResult> {
|
|
const {
|
|
query,
|
|
provider: providerId,
|
|
maxResults,
|
|
searchType,
|
|
country,
|
|
language,
|
|
timeRange,
|
|
offset,
|
|
domainFilter,
|
|
contentOptions,
|
|
providerOptions,
|
|
credentials,
|
|
alternateProvider,
|
|
alternateCredentials,
|
|
log,
|
|
} = options;
|
|
const startTime = Date.now();
|
|
|
|
// 1. Sanitize input
|
|
const { clean: cleanQuery, error: sanitizeError } = sanitizeQuery(query);
|
|
if (sanitizeError) {
|
|
return { success: false, status: 400, error: sanitizeError };
|
|
}
|
|
|
|
// 2. Use resolved provider from route (no re-resolution)
|
|
const primaryConfig = getSearchProvider(providerId);
|
|
if (!primaryConfig) {
|
|
return {
|
|
success: false,
|
|
status: 400,
|
|
error: `Unknown search provider: ${providerId}`,
|
|
};
|
|
}
|
|
|
|
// 3. Get alternate config for failover (pre-resolved by route)
|
|
const alternateConfig = alternateProvider ? getSearchProvider(alternateProvider) : null;
|
|
|
|
const requestParams = {
|
|
query: cleanQuery,
|
|
searchType,
|
|
maxResults,
|
|
country,
|
|
language,
|
|
timeRange,
|
|
offset,
|
|
domainFilter,
|
|
contentOptions,
|
|
providerOptions,
|
|
};
|
|
|
|
// 4. Try primary provider
|
|
const result = await tryProvider(primaryConfig, requestParams, credentials, startTime, log);
|
|
|
|
if (result.success) return result;
|
|
|
|
// 5. Failover to alternate (only for retriable errors and auto-select mode)
|
|
if (
|
|
alternateConfig &&
|
|
alternateCredentials &&
|
|
!NON_RETRIABLE.has(result.status || 0) &&
|
|
Date.now() - startTime < GLOBAL_TIMEOUT_MS
|
|
) {
|
|
if (log) {
|
|
log.warn(
|
|
"SEARCH",
|
|
`${primaryConfig.id} failed (${result.status}), trying ${alternateConfig.id}`
|
|
);
|
|
}
|
|
|
|
const fallbackResult = await tryProvider(
|
|
alternateConfig,
|
|
requestParams,
|
|
alternateCredentials,
|
|
startTime,
|
|
log
|
|
);
|
|
|
|
if (fallbackResult.success) return fallbackResult;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function tryProvider(
|
|
config: SearchProviderConfig,
|
|
params: Omit<SearchRequestParams, "token">,
|
|
credentials: Record<string, any>,
|
|
globalStartTime: number,
|
|
log?: any
|
|
): Promise<SearchHandlerResult> {
|
|
const startTime = Date.now();
|
|
const providerSpecificData =
|
|
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
|
? credentials.providerSpecificData
|
|
: undefined;
|
|
const token = credentials.apiKey || credentials.accessToken || undefined;
|
|
|
|
if (config.authType !== "none" && !token) {
|
|
return {
|
|
success: false,
|
|
status: 401,
|
|
error: `No credentials for search provider: ${config.id}`,
|
|
};
|
|
}
|
|
|
|
const { query, searchType, maxResults } = params;
|
|
|
|
if (config.id === "zai-search" && token) {
|
|
return tryZaiMCPProvider(
|
|
config,
|
|
params,
|
|
token,
|
|
providerSpecificData,
|
|
startTime,
|
|
globalStartTime,
|
|
log
|
|
);
|
|
}
|
|
|
|
let url = "";
|
|
let init: RequestInit = {};
|
|
try {
|
|
({ url, init } = buildRequest(config, { ...params, token, providerSpecificData }));
|
|
} catch (err: any) {
|
|
return {
|
|
success: false,
|
|
status: 400,
|
|
error: err?.message || `Invalid search configuration for provider: ${config.id}`,
|
|
};
|
|
}
|
|
|
|
// Timeout: min of provider timeout and remaining global timeout
|
|
const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime);
|
|
const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000));
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
if (log) {
|
|
log.info("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
clearTimeout(timer);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
if (log) {
|
|
log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`);
|
|
}
|
|
|
|
saveCallLog({
|
|
method: config.method,
|
|
path: "/v1/search",
|
|
status: response.status,
|
|
model: config.id,
|
|
provider: config.id,
|
|
duration: Date.now() - startTime,
|
|
requestType: "search",
|
|
error: errorText.slice(0, 500),
|
|
requestBody: {
|
|
query: query.slice(0, 200),
|
|
search_type: searchType,
|
|
max_results: maxResults,
|
|
},
|
|
}).catch(() => {
|
|
/* non-critical — logging must not block search response */
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
status: response.status,
|
|
error: `Search provider ${config.id} returned ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const data = await response.json();
|
|
const normalized = normalizeResponse(config.id, data, query, searchType);
|
|
// Enforce max_results — some providers return more than requested
|
|
const results = normalized.results.slice(0, maxResults);
|
|
const totalResults = normalized.totalResults;
|
|
const duration = Date.now() - startTime;
|
|
|
|
saveCallLog({
|
|
method: config.method,
|
|
path: "/v1/search",
|
|
status: 200,
|
|
model: config.id,
|
|
provider: config.id,
|
|
duration,
|
|
requestType: "search",
|
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
|
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
|
responseBody: { results_count: results.length, cached: false },
|
|
}).catch(() => {
|
|
/* non-critical — logging must not block search response */
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
provider: config.id,
|
|
query,
|
|
results,
|
|
answer: null,
|
|
usage: { queries_used: 1, search_cost_usd: config.costPerQuery },
|
|
metrics: {
|
|
response_time_ms: duration,
|
|
upstream_latency_ms: duration,
|
|
total_results_available: totalResults,
|
|
},
|
|
errors: [],
|
|
},
|
|
};
|
|
} catch (err: any) {
|
|
clearTimeout(timer);
|
|
|
|
const isTimeout = err.name === "AbortError";
|
|
if (log) {
|
|
log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${err.message}`);
|
|
}
|
|
|
|
saveCallLog({
|
|
method: config.method,
|
|
path: "/v1/search",
|
|
status: isTimeout ? 504 : 502,
|
|
model: config.id,
|
|
provider: config.id,
|
|
duration: Date.now() - startTime,
|
|
requestType: "search",
|
|
error: err.message,
|
|
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
|
|
}).catch(() => {
|
|
/* non-critical — logging must not block search response */
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
status: isTimeout ? 504 : 502,
|
|
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`,
|
|
};
|
|
}
|
|
}
|