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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user