import { activityMonitor } from "./activity.ts"; import type { SearchOptions, SearchResult, SearchResponse } from "./perplexity.ts"; import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts"; // Endpoint, key, and timeout are environment-only by design (no web-search.json fallback): // this provider wraps an internal CloudsWay-backed search service, not a third-party API key // a user would want to store alongside the other providers' config file entries. const DEFAULT_TIMEOUT_MS = 30_000; interface NormalizedDomainFilters { allowed: string[]; blocked: string[]; } interface CloudsWayResultItem { name?: string; url?: string; snippet?: string; } interface CloudsWayResponse { webPages?: { value?: CloudsWayResultItem[]; }; } interface CloudsWayErrorBody { error?: { code?: string; message?: string; }; } function getEndpoint(): string | null { const value = process.env.CLOUDS_WAY_SEARCH_ENDPOINT; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } async function getApiKey(signal?: AbortSignal): Promise { return resolveCredential({ provider: "Felo Search", configuredValue: undefined, environmentValue: process.env.CLOUDS_WAY_SEARCH_KEY, signal, }); } async function requireApiKey(signal?: AbortSignal): Promise { const apiKey = await getApiKey(signal); if (!apiKey) { throw new Error( "Felo Search API key not found. Set the CLOUDS_WAY_SEARCH_KEY environment variable.", ); } return apiKey; } function getTimeoutMs(): number { const raw = process.env.CLOUDS_WAY_SEARCH_TIMEOUT_MS; if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_TIMEOUT_MS; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS; return parsed; } function requestSignal(signal?: AbortSignal): AbortSignal { const timeout = AbortSignal.timeout(getTimeoutMs()); return signal ? AbortSignal.any([signal, timeout]) : timeout; } function normalizeCount(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) return 5; return Math.max(1, Math.min(Math.floor(value), 20)); } function normalizeDomain(value: string): string | null { let input = value.trim().toLowerCase(); if (!input) return null; if (input.startsWith("-")) input = input.slice(1).trim(); if (!input) return null; try { const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`); input = parsed.hostname; } catch { input = input.split("/")[0]?.split(":")[0] ?? ""; } input = input.replace(/^\.+|\.+$/g, ""); return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null; } function normalizeDomainFilters(domainFilter: string[] | undefined): NormalizedDomainFilters { const filters: NormalizedDomainFilters = { allowed: [], blocked: [] }; for (const raw of domainFilter ?? []) { const domain = normalizeDomain(raw); if (!domain) continue; const target = raw.trim().startsWith("-") ? filters.blocked : filters.allowed; if (!target.includes(domain)) target.push(domain); } return filters; } function hostMatchesDomain(hostname: string, domain: string): boolean { return hostname === domain || hostname.endsWith(`.${domain}`); } function matchesDomainFilters(url: string, filters: NormalizedDomainFilters): boolean { if (filters.allowed.length === 0 && filters.blocked.length === 0) return true; try { const hostname = new URL(url).hostname.toLowerCase(); if (filters.allowed.length > 0 && !filters.allowed.some(domain => hostMatchesDomain(hostname, domain))) return false; return !filters.blocked.some(domain => hostMatchesDomain(hostname, domain)); } catch { return false; } } function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } async function parseErrorBody(response: Response): Promise { const text = await response.text(); try { const parsed = JSON.parse(text) as CloudsWayErrorBody; if (parsed?.error?.message) { const code = parsed.error.code ? ` (code=${parsed.error.code})` : ""; return `${parsed.error.message}${code}`; } } catch { // fall through to raw body below } return text.slice(0, 300); } function mapResults(items: CloudsWayResultItem[] | undefined, numResults: number, filters: NormalizedDomainFilters): SearchResult[] { if (!Array.isArray(items)) return []; const results: SearchResult[] = []; for (const item of items) { const url = typeof item.url === "string" ? item.url : ""; if (!url) continue; if (!matchesDomainFilters(url, filters)) continue; results.push({ title: item.name || `Source ${results.length + 1}`, url, snippet: typeof item.snippet === "string" ? item.snippet : "", }); if (results.length >= numResults) break; } return results; } function buildAnswer(results: SearchResult[]): string { return results .map(result => (result.snippet ? `${result.snippet}\nSource: ${result.title} (${result.url})` : `Source: ${result.title} (${result.url})`)) .join("\n\n"); } export function isFeloSearchAvailable(): boolean { if (!getEndpoint()) return false; return hasCredentialSource({ provider: "Felo Search", configuredValue: undefined, environmentValue: process.env.CLOUDS_WAY_SEARCH_KEY, }); } export async function searchWithFeloSearch(query: string, options: SearchOptions = {}): Promise { const endpoint = getEndpoint(); if (!endpoint) { throw new Error( "Felo Search endpoint not found. Set the CLOUDS_WAY_SEARCH_ENDPOINT environment variable " + "(e.g. https://searchapi.cloudsway.net/search//smart).", ); } const apiKey = await requireApiKey(options.signal); const numResults = normalizeCount(options.numResults); const filters = normalizeDomainFilters(options.domainFilter); const url = new URL(endpoint); url.searchParams.set("q", query); url.searchParams.set("count", String(numResults)); const activityId = activityMonitor.logStart({ type: "api", query }); let response: Response; try { response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, }, signal: requestSignal(options.signal), }); } catch (err) { const message = errorMessage(err); const redactedMessage = redactCredential(message, apiKey); if (redactedMessage.toLowerCase().includes("abort")) activityMonitor.logComplete(activityId, 0); else activityMonitor.logError(activityId, redactedMessage); if (redactedMessage === message) throw err; const redactedError = new Error(redactedMessage); if (err instanceof Error) redactedError.name = err.name; throw redactedError; } if (response.status === 429) { activityMonitor.logComplete(activityId, response.status); const detail = redactCredential(await parseErrorBody(response), apiKey); throw new Error(`Felo Search rate limit reached (429): ${detail}`); } if (!response.ok) { activityMonitor.logComplete(activityId, response.status); const detail = redactCredential(await parseErrorBody(response), apiKey); throw new Error(`Felo Search error ${response.status}: ${detail}`); } let data: CloudsWayResponse; try { data = await response.json() as CloudsWayResponse; } catch (err) { activityMonitor.logComplete(activityId, response.status); throw new Error(`Felo Search returned invalid JSON: ${errorMessage(err)}`); } activityMonitor.logComplete(activityId, response.status); const results = mapResults(data.webPages?.value, numResults, filters); return { answer: buildAnswer(results), results }; }