/** * Kagi Search API client — supports both v1 (default) and v0 (legacy). * - v1: POST https://kagi.com/api/v1/search, `Authorization: Bearer `. * - v0: GET https://kagi.com/api/v0/search, `Authorization: Bot ` (deprecated). * Both share `kagiRequest` (timeout/abort/JSON) and `mapKagiData` (response → results). * * The endpoint comes from global config only (never project config) and must be * https — the token rides in the Authorization header, so an http or attacker- * controlled endpoint would leak it. A per-request timeout guards against a * hung Kagi connection independently of the outer abort signal. */ import { readCappedText } from "../fetch/safe-fetch.ts"; import type { SearchResult } from "./search.ts"; /** @deprecated use SearchResult — kept as an alias to avoid churn. */ export type KagiResult = SearchResult; const KAGI_TIMEOUT_MS = 15_000; const MAX_KAGI_BYTES = 5_000_000; interface KagiApiItem { t?: number; // v0 discriminator (0 = search result, 1 = related search); absent in v1 url?: string; title?: string; snippet?: string; published?: string; // v0 date field time?: string; // v1 date field } /** Assert the endpoint is safe to send the bearer token to (https, or explicit localhost opt-in). */ export function assertSafeEndpoint(endpoint: string, allowPrivateNetwork: boolean): URL { let u: URL; try { u = new URL(endpoint); } catch { throw new Error(`invalid kagiEndpoint: ${endpoint}`); } if (u.protocol === "https:") return u; if (u.protocol === "http:" && allowPrivateNetwork) return u; throw new Error(`kagiEndpoint must be https (got "${u.protocol}//${u.host}")`); } interface KagiResponse { // v0: `data` is a flat array; v1: `data` is an object of buckets // ({ search, related_search, infobox, video, … }). Kept `unknown` so each client // extracts the right shape. data?: unknown; error?: unknown; // v0 error field errors?: Array<{ code?: string; message?: string }>; // v1 error field (array) meta?: { api_balance?: number; [k: string]: unknown }; } /** Human-readable error from either schema: v1 `errors[]` or v0 `error`. */ function extractKagiError(parsed: KagiResponse | undefined): string | undefined { if (!parsed) return undefined; if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { return parsed.errors.map((e) => e?.message || e?.code || JSON.stringify(e)).join("; "); } if (parsed.error) return typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error).slice(0, 300); return undefined; } // 401/403 are almost always a key/version mismatch — point at the likely cause. const KAGI_AUTH_HINT = " — verify the key matches the selected API version (v1 ('kagi') needs a Bearer key from " + "kagi.com/api/keys; v0 ('kagi-v0') needs a 'Bot' token). Note: a real KAGI_API_KEY env var overrides .env."; /** * Map Kagi result rows to normalized SearchResults. Tolerant of both schemas: * v0 tags each row with a `t` discriminator (0 = search result, 1 = related * search) and dates them with `published`; v1 has no `t` (results are already * bucketed) and dates with `time`. Keep rows with a string `url` that are either * search results (`t === 0`) or untyped (`t === undefined`); drop related-searches * and url-less rows. */ export function mapKagiData(items: KagiApiItem[]): SearchResult[] { return items .filter((d): d is KagiApiItem & { url: string } => typeof d?.url === "string" && (d.t === 0 || d.t === undefined)) .map((d) => ({ url: d.url, title: d.title ?? d.url, snippet: d.snippet, published: d.published ?? d.time })); } /** Coerce v0's flat `data` array (defensive against an unexpected shape). */ function v0Items(data: unknown): KagiApiItem[] { return Array.isArray(data) ? (data as KagiApiItem[]) : []; } /** Extract v1's web-results bucket: `data.search` (the other buckets are ignored). */ function v1Items(data: unknown): KagiApiItem[] { const search = (data as { search?: unknown } | undefined)?.search; return Array.isArray(search) ? (search as KagiApiItem[]) : []; } /** * Shared request plumbing for both API versions: per-request timeout, abort * wiring, status + JSON handling. The endpoint is a trusted (global-config, * https-asserted) API host, so this uses plain `fetch` rather than the SSRF-guarded * `safeFetch`. Returns the parsed response or throws a descriptive Error. */ async function kagiRequest(url: URL, init: RequestInit, signal?: AbortSignal): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), KAGI_TIMEOUT_MS); const onAbort = () => ctrl.abort(); signal?.addEventListener("abort", onAbort, { once: true }); let res: Response; try { res = await fetch(url, { ...init, signal: ctrl.signal }); } catch (e) { const err = e as { name?: string; message?: string }; throw new Error(err.name === "AbortError" ? "Kagi request timed out" : err.message || String(e)); } finally { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); } // Read + parse the body once; it carries error details for both ok and !ok. const raw = await readCappedText(res, MAX_KAGI_BYTES).catch(() => ""); let json: KagiResponse | undefined; try { json = raw ? (JSON.parse(raw) as KagiResponse) : undefined; } catch { json = undefined; } if (!res.ok) { const msg = extractKagiError(json) ?? raw.slice(0, 300) ?? res.statusText; const hint = res.status === 401 || res.status === 403 ? KAGI_AUTH_HINT : ""; throw new Error(`Kagi API ${res.status} ${res.statusText}: ${msg}${hint}`); } if (!json) throw new Error("Kagi API returned non-JSON response"); const errMsg = extractKagiError(json); if (errMsg) { const bal = json.meta?.api_balance; throw new Error(`Kagi API error: ${errMsg}${typeof bal === "number" ? ` (api_balance: ${bal})` : ""}`); } return json; } /** Kagi v0 (legacy) Search API: GET with `Authorization: Bot `. */ export async function kagiSearch( query: string, count: number, token: string, endpoint: string, allowPrivateNetwork: boolean, signal?: AbortSignal, ): Promise { const url = assertSafeEndpoint(endpoint, allowPrivateNetwork); url.searchParams.set("q", query); url.searchParams.set("limit", String(count)); const json = await kagiRequest(url, { headers: { Authorization: `Bot ${token}` } }, signal); return mapKagiData(v0Items(json.data)); } /** Kagi v1 Search API: POST JSON body with `Authorization: Bearer `. */ export async function kagiSearchV1( query: string, count: number, token: string, endpoint: string, allowPrivateNetwork: boolean, signal?: AbortSignal, ): Promise { const url = assertSafeEndpoint(endpoint, allowPrivateNetwork); const json = await kagiRequest( url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ query, limit: count }), }, signal, ); return mapKagiData(v1Items(json.data)); }