/** * Search orchestration: resolve one provider, throttle, run, map the outcome. * * The orchestrator owns everything shared across providers — selection, key * resolution, politeness spacing, and turning provider errors into a * model-readable `WebSearchError`. Providers stay pure. The throttle is a value * object created per extension instance, so there is no module-level state. */ import { messageOf } from "../../../lib/errors.ts"; import { abortableSleep } from "../../../lib/timer.ts"; import { createFetchRunner, type HttpRunner } from "../http.ts"; import { resolveSearchApiKey, type WebSearchConfig } from "./config.ts"; import { resolveProvider } from "./registry.ts"; import type { SearchProviderId, SearchProviderName, SearchRequest, SearchResult } from "./types.ts"; /** Error type for anything the model should see as a failed search. */ export class WebSearchError extends Error { constructor(message: string) { super(message); this.name = "WebSearchError"; } } interface SearchOutcome { provider: SearchProviderName; answer: string; results: SearchResult[]; } /** A politeness throttle. Create one per extension instance, not per module. */ export interface Throttle { wait(minIntervalMs: number, signal?: AbortSignal): Promise; } export interface SearchDeps { http?: HttpRunner; throttle?: Throttle; } /** A sleeper, injectable so throttle scheduling is testable without wall time. */ type ThrottleSleep = (ms: number, signal?: AbortSignal) => Promise; /** The shared abortable sleep, mapped to the one error this extension throws. */ const delay: ThrottleSleep = async (ms, signal) => { try { await abortableSleep(ms, signal); } catch { throw new WebSearchError("Search aborted."); } }; /** * A stateful throttle that spaces requests by `minIntervalMs`. `now` and `sleep` * are injectable so tests can advance a fake clock instead of waiting. */ export function createThrottle(now: () => number = Date.now, sleep: ThrottleSleep = delay): Throttle { let nextAllowedAt = 0; return { async wait(minIntervalMs, signal) { if (minIntervalMs <= 0) return; const current = now(); const wait = nextAllowedAt - current; nextAllowedAt = current + Math.max(0, wait) + minIntervalMs; if (wait > 0) await sleep(wait, signal); }, }; } /** A model-readable reason a forced provider cannot run. */ function notConfigured(id: SearchProviderId): string { if (id === "searxng") { return 'No SearXNG endpoint configured. Set "web-access.search.endpoint" in bytefly.json (for example http://localhost:8080).'; } if (id === "brave") { return 'Brave search needs an API key. Set "web-access.search.apiKeyEnv" (preferred) or "web-access.search.apiKey" in bytefly.json.'; } return `The "${id}" search provider is not configured.`; } /** * Run one search against the resolved provider. Throws `WebSearchError` when the * provider is unconfigured or the request fails; a `"none"` outcome means the * query simply had no results. */ export async function runSearch( request: SearchRequest, config: WebSearchConfig, signal: AbortSignal | undefined, deps: SearchDeps = {}, ): Promise { const provider = resolveProvider(config); if (!provider.isConfigured(config)) throw new WebSearchError(notConfigured(provider.id)); const http = deps.http ?? createFetchRunner(); const throttle = deps.throttle ?? createThrottle(); await throttle.wait(config.minIntervalMs, signal); let result: { answer: string; results: SearchResult[] }; try { result = await provider.search({ request, config, http, signal, apiKey: resolveSearchApiKey(config), }); } catch (error) { if (error instanceof WebSearchError) throw error; throw new WebSearchError(`Search failed: ${messageOf(error)}`); } if (!result.answer && result.results.length === 0) { return { provider: "none", answer: "", results: [] }; } return { provider: provider.id, answer: result.answer, results: result.results }; }