/** * Wyna Search API client. * * Endpoint: GET/POST https://ai.wyna.info/api/search * Auth: Authorization: Bearer (same key as the Wyna LLM gateway) * * The key is read from the Pi provider config (~/.pi/agent/models.json → * providers.wyna.apiKey) or from $WYNA_API_KEY — see resolveWynaApiKey in * config.ts. No separate credential is needed. * * Response is a flat JSON array of results (with optional "related" queries and * a "cost_chf" meter stamp), unlike Kagi's bucketed `data` object. The optional * HTML bold tags ( in snippets) are stripped before returning. */ import { readCappedText } from "../fetch/safe-fetch.ts"; import type { SearchResult } from "./search.ts"; const WYNA_TIMEOUT_MS = 15_000; const MAX_WYNA_BYTES = 5_000_000; // Strip inline HTML tags (, , , , …) from a snippet string. function stripHtmlTags(s: string): string { return s.replace(/<[^>]*>/g, ""); } interface WynaApiItem { title: string; url: string; snippet?: string; published?: string | null; } interface WynaApiResponse { query: string; results: WynaApiItem[]; related?: string[]; cost_chf?: number; } /** Human-readable error from the Wyna `{"detail": "message"}` schema. */ function extractWynaError(raw: string | undefined): string | undefined { if (!raw) return undefined; try { const parsed = JSON.parse(raw) as { detail?: string }; if (typeof parsed.detail === "string" && parsed.detail.length > 0) return parsed.detail; } catch { /* not JSON — fall through to raw text */ } return undefined; } /** * Map Wyna result rows to normalized SearchResults. Strips any HTML tags from * snippets. Published can be null/missing — we pass it through as-is. */ export function mapWynaData(items: WynaApiItem[]): SearchResult[] { return items .filter((d): d is WynaApiItem & { url: string } => typeof d?.url === "string" && d.url.length > 0) .map((d) => ({ url: d.url, title: d.title || d.url, snippet: d.snippet ? stripHtmlTags(d.snippet) : undefined, published: d.published ?? undefined, })); } /** * Wyna Search API: supports both GET and POST. The method is chosen by the * caller — GET is simpler and cache-friendly, POST avoids URL-length limits. * Auth is always `Authorization: Bearer `. */ async function wynaRequest( endpoint: string, query: string, count: number, token: string, method: "GET" | "POST", signal?: AbortSignal, ): Promise { const url = new URL(endpoint); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), WYNA_TIMEOUT_MS); const onAbort = () => ctrl.abort(); signal?.addEventListener("abort", onAbort, { once: true }); const headers: Record = { Authorization: `Bearer ${token}`, Accept: "application/json", }; let fetchUrl: string; let body: string | undefined; if (method === "GET") { url.searchParams.set("q", query); url.searchParams.set("limit", String(count)); fetchUrl = url.toString(); } else { headers["Content-Type"] = "application/json"; fetchUrl = url.toString(); body = JSON.stringify({ q: query, limit: count }); } let res: Response; try { res = await fetch(fetchUrl, { method, headers, body, signal: ctrl.signal, }); } catch (e) { const err = e as { name?: string; message?: string }; throw new Error(err.name === "AbortError" ? "Wyna search request timed out" : err.message || String(e)); } finally { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); } // Read body once (size-capped) for both status/error handling and JSON parsing. const raw = await readCappedText(res, MAX_WYNA_BYTES).catch(() => ""); if (!res.ok) { const detail = extractWynaError(raw) ?? raw.slice(0, 300) ?? res.statusText; const status = res.status; if (status === 400) { throw new Error(`Wyna search API 400: ${detail}`); } if (status === 401) { throw new Error( `Wyna search API 401: ${detail} — check your Wyna API key`.trim(), ); } if (status === 402) { // Balance exhausted / account blocked — show the detail verbatim (user-facing). throw new Error(`Wyna search API 402: ${detail}`); } if (status === 429) { throw new Error(`Wyna search API 429: rate limited — ${detail}`); } if (status === 502) { throw new Error(`Wyna search API 502: upstream unavailable — ${detail}`); } if (status === 503) { throw new Error(`Wyna search API 503: search not configured — ${detail}`); } throw new Error(`Wyna search API ${status}: ${detail}`); } let json: WynaApiResponse; try { json = JSON.parse(raw) as WynaApiResponse; } catch { throw new Error("Wyna search API returned non-JSON response"); } return json; } /** Wyna Search API: GET request. Simpler, cache-friendly. */ export async function wynaSearchGet( query: string, count: number, token: string, endpoint: string, signal?: AbortSignal, ): Promise { const json = await wynaRequest(endpoint, query, count, token, "GET", signal); return mapWynaData(json.results); } /** Wyna Search API: POST request. Avoids URL-length limits for long queries. */ export async function wynaSearchPost( query: string, count: number, token: string, endpoint: string, signal?: AbortSignal, ): Promise { const json = await wynaRequest(endpoint, query, count, token, "POST", signal); return mapWynaData(json.results); }