feat(web-search): Added Kagi V1 API Provider

- Added Kagi V1 to settings-schema
- Added Kagi V1 API Client
- Added Kagi V1 provider to the search/provider list
- Added Kagi V1 search provider
- Added Kagi V1 to search/types
- Added tests in web-search-kagi-v1.test
- Added Kagi V1 to CHANGELOG
This commit is contained in:
thismat
2026-05-21 13:36:51 -05:00
parent 8dc9125b0b
commit a9a2ba7e44
7 changed files with 647 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
/**
* Kagi V1 API Client
*
* Implements the Kagi V1 Search API (POST /api/v1/search) which differs from
* the legacy V0 API (GET /api/v0/search) in authentication, request format,
* and response structure.
*/
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
import { findCredential } from "./search/providers/utils";
const KAGI_V1_SEARCH_URL = "https://kagi.com/api/v1/search";
// ---------------------------------------------------------------------------
// V1 Request / Response Types
// ---------------------------------------------------------------------------
/** V1 search request body */
export interface KagiV1SearchRequest {
query: string;
/** Workflow mode: "search" | "research" */
workflow?: string;
/** Number of results (1-100) */
limit?: number;
/** Lens identifier (e.g., "news", "reddit") */
lens?: string;
/** Time-based filter: ISO date string or relative (day|week|month) */
filters?: {
time_after?: string;
time_before?: string;
};
}
/** Individual V1 result item */
export interface KagiV1SearchResultItem {
url: string;
title: string;
snippet?: string;
/** ISO timestamp or relative ("2h ago") */
time?: string;
/** Thumbnail URL */
image?: string;
/** Extra metadata key-value pairs */
props?: Record<string, string>;
}
/** V1 response data (categorized) */
export interface KagiV1SearchData {
search?: KagiV1SearchResultItem[];
video?: KagiV1SearchResultItem[];
news?: KagiV1SearchResultItem[];
infobox?: KagiV1SearchResultItem[];
related_search?: string[];
direct_answer?: Array<{ text: string }>;
}
/** V1 success response */
export interface KagiV1SearchResponse {
meta?: {
id: string;
};
data?: KagiV1SearchData;
error?: KagiV1ErrorEntry[];
}
/** V1 error entry */
export interface KagiV1ErrorEntry {
code?: number;
url?: string;
message?: string;
location?: string;
}
/** V1 error response */
export interface KagiV1ErrorResponse {
meta?: Record<string, unknown>;
error?: KagiV1ErrorEntry[];
}
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
export class KagiV1ApiError extends Error {
readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message);
this.name = "KagiV1ApiError";
this.statusCode = statusCode;
}
}
function extractKagiV1ErrorMessage(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
// Try message field first
if (typeof record.message === "string" && record.message.trim().length > 0) {
return record.message.trim();
}
// Try V1 error array
if (Array.isArray(record.error)) {
for (const entry of record.error) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
if (typeof e.message === "string" && e.message.trim().length > 0) {
return e.message.trim();
}
if (typeof e.msg === "string" && e.msg.trim().length > 0) {
return e.msg.trim();
}
}
}
// Fallback: stringify the whole payload
const keys = Object.keys(record);
if (keys.length > 0) {
const first = record[keys[0]];
if (typeof first === "string" && first.trim().length > 0) {
return first.trim();
}
}
return null;
}
function createKagiV1ApiError(statusCode: number, detail?: string): KagiV1ApiError {
const msg = detail ? `Kagi V1 API error (${statusCode}): ${detail}` : `Kagi V1 API error (${statusCode})`;
return new KagiV1ApiError(msg, statusCode);
}
function parseKagiV1ErrorResponse(statusCode: number, responseText: string): KagiV1ApiError {
const trimmed = responseText.trim();
if (trimmed.length === 0) {
return createKagiV1ApiError(statusCode);
}
try {
const payload = JSON.parse(trimmed) as KagiV1ErrorResponse;
return createKagiV1ApiError(statusCode, extractKagiV1ErrorMessage(payload) ?? trimmed);
} catch {
return createKagiV1ApiError(statusCode, trimmed);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface KagiV1SearchOptions {
limit?: number;
recency?: "day" | "week" | "month" | "year";
signal?: AbortSignal;
}
export interface KagiV1SearchSource {
title: string;
url: string;
snippet?: string;
publishedDate?: string;
}
export interface KagiV1SearchResult {
requestId: string;
sources: KagiV1SearchSource[];
relatedQuestions: string[];
answer?: string;
}
/**
* Find the Kagi API key (same key works for both V0 and V1 APIs).
* Checks KAGI_API_KEY env var, then keychain storage.
*/
export async function findKagiApiKey(): Promise<string | null> {
return findCredential(getEnvApiKey("kagi"), "kagi");
}
function buildRequestBody(query: string, options: KagiV1SearchOptions): KagiV1SearchRequest {
const req: KagiV1SearchRequest = {
query,
workflow: "search",
limit: options.limit,
};
// Map recency to V1 time filters
if (options.recency) {
switch (options.recency) {
case "day": {
req.filters = { ...req.filters, time_after: "1d ago" };
break;
}
case "week": {
req.filters = { ...req.filters, time_after: "1w ago" };
break;
}
case "month": {
req.filters = { ...req.filters, time_after: "1mo ago" };
break;
}
case "year": {
req.filters = { ...req.filters, time_after: "1y ago" };
break;
}
}
}
return req;
}
export async function searchWithKagiV1(query: string, options: KagiV1SearchOptions = {}): Promise<KagiV1SearchResult> {
const apiKey = await findKagiApiKey();
if (!apiKey) {
throw new KagiV1ApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
}
const requestBody = buildRequestBody(query, options);
const response = await fetch(KAGI_V1_SEARCH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(requestBody),
signal: options.signal,
});
if (!response.ok) {
throw parseKagiV1ErrorResponse(response.status, await response.text());
}
const payload = (await response.json()) as KagiV1SearchResponse;
if (payload.error && payload.error.length > 0) {
const first = payload.error[0];
throw createKagiV1ApiError(first.code ?? 400, extractKagiV1ErrorMessage(payload) ?? first.message);
}
const sources: KagiV1SearchSource[] = [];
const relatedQuestions: string[] = [];
let answer: string | undefined;
const data = payload.data;
// V1 categorizes results; collect from each category
if (data?.search) {
for (const item of data.search) {
sources.push({
title: item.title,
url: item.url,
snippet: item.snippet,
publishedDate: item.time,
});
}
}
if (data?.video) {
for (const item of data.video) {
sources.push({
title: `[Video] ${item.title}`,
url: item.url,
snippet: item.snippet,
publishedDate: item.time,
});
}
}
if (data?.news) {
for (const item of data.news) {
sources.push({
title: `[News] ${item.title}`,
url: item.url,
snippet: item.snippet,
publishedDate: item.time,
});
}
}
if (data?.infobox) {
for (const item of data.infobox) {
sources.push({
title: `[Info] ${item.title}`,
url: item.url,
snippet: item.snippet,
publishedDate: item.time,
});
}
}
// Related searches
if (data?.related_search) {
relatedQuestions.push(...data.related_search);
}
// Direct answer
if (data?.direct_answer && data.direct_answer.length > 0) {
answer = data.direct_answer[0].text;
}
return {
requestId: payload.meta?.id ?? "",
sources,
relatedQuestions,
answer,
};
}
@@ -82,6 +82,11 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
label: "Kagi",
load: async () => new (await import("./providers/kagi")).KagiProvider(),
},
"kagi-v1": {
id: "kagi-v1",
label: "Kagi V1",
load: async () => new (await import("./providers/kagi-v1")).KagiV1Provider(),
},
synthetic: {
id: "synthetic",
label: "Synthetic",
@@ -130,6 +135,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
"exa",
"parallel",
"kagi",
"kagi-v1",
"synthetic",
"searxng",
];
@@ -0,0 +1,69 @@
/**
* Kagi V1 Web Search Provider
*
* Thin wrapper that adapts shared Kagi V1 API utilities to SearchResponse shape.
*/
import type { SearchResponse } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
import { findKagiApiKey, KagiV1ApiError, searchWithKagiV1 } from "../../kagi-v1";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
import { toSearchSources } from "./utils";
const DEFAULT_NUM_RESULTS = 10;
const MAX_NUM_RESULTS = 40;
/** Execute Kagi V1 web search. */
export async function searchKagiV1(params: {
query: string;
num_results?: number;
recency?: SearchParams["recency"];
signal?: AbortSignal;
}): Promise<SearchResponse> {
const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
try {
const result = await searchWithKagiV1(params.query, {
limit: numResults,
recency: params.recency,
signal: params.signal,
});
return {
provider: "kagi-v1",
sources: toSearchSources(result.sources, numResults),
relatedQuestions: result.relatedQuestions.length > 0 ? result.relatedQuestions : undefined,
requestId: result.requestId,
answer: result.answer,
};
} catch (err) {
if (err instanceof KagiV1ApiError) {
throw new SearchProviderError("kagi-v1", err.message, err.statusCode);
}
throw err;
}
}
/** Search provider for Kagi V1 web search. */
export class KagiV1Provider extends SearchProvider {
readonly id = "kagi-v1";
readonly label = "Kagi V1";
async isAvailable() {
try {
return !!(await findKagiApiKey());
} catch {
return false;
}
}
search(params: SearchParams): Promise<SearchResponse> {
return searchKagiV1({
query: params.query,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
signal: params.signal,
});
}
}
@@ -18,6 +18,7 @@ export type SearchProviderId =
| "tavily"
| "parallel"
| "kagi"
| "kagi-v1"
| "synthetic"
| "searxng";
@@ -35,6 +36,7 @@ export function isSearchProviderId(value: string): value is SearchProviderId {
"tavily",
"parallel",
"kagi",
"kagi-v1",
"synthetic",
"searxng",
].includes(value);