Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/tools/search/keenable-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import axios from 'axios';
import type * as t from './types';
import { DATE_RANGE } from './schema';

const DEFAULT_KEENABLE_TIMEOUT = 15000;

/** Authenticated and keyless endpoints. Keenable works without an API key by
* falling back to the public endpoint; a key only lifts rate limits. */
const KEENABLE_DEFAULT_API_URL = 'https://api.keenable.ai/v1/search';
const KEENABLE_PUBLIC_API_URL = 'https://api.keenable.ai/v1/search/public';
const KEENABLE_DATE_RANGES: Record<DATE_RANGE, string> = {
[DATE_RANGE.PAST_HOUR]: '1h',
[DATE_RANGE.PAST_24_HOURS]: '1d',
[DATE_RANGE.PAST_WEEK]: '7d',
[DATE_RANGE.PAST_MONTH]: '1mo',
[DATE_RANGE.PAST_YEAR]: '1y',
};

export const createKeenableAPI = (
apiKey?: string,
apiUrl?: string,
options?: t.KeenableSearchOptions
): {
getSources: (params: t.GetSourcesParams) => Promise<t.SearchResult>;
} => {
const resolvedKey = apiKey ?? process.env.KEENABLE_API_KEY;
const hasKey = resolvedKey != null && resolvedKey !== '';
const timeout = options?.timeout ?? DEFAULT_KEENABLE_TIMEOUT;
const resolvedUrl =
apiUrl ??
process.env.KEENABLE_API_URL ??
(hasKey ? KEENABLE_DEFAULT_API_URL : KEENABLE_PUBLIC_API_URL);

/** Constant for the provider's lifetime. X-Keenable-Title is required for
* keyless requests and used for traffic attribution; the API key only lifts
* rate limits, so it is sent only when present. */
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Keenable-Title': options?.attributionTitle ?? 'LibreChat',
};
if (hasKey) {
headers['X-API-Key'] = resolvedKey;
}

const getSources = async ({
query,
date,
numResults = 8,
}: t.GetSourcesParams): Promise<t.SearchResult> => {
if (!query.trim()) {
return { success: false, error: 'Query cannot be empty' };
}

try {
/** Keenable's endpoint has no result-count parameter; the count is
* applied client-side after the response (see slice below). */
const payload: t.KeenableSearchPayload = { query };
if (options?.site != null && options.site !== '') {
payload.site = options.site;
}
if (date != null) {
payload.published_after = KEENABLE_DATE_RANGES[date];
}

const response = await axios.post<t.KeenableSearchResponse>(
resolvedUrl,
payload,
{ headers, timeout }
);

const maxResults = Math.min(
Math.max(1, options?.maxResults ?? numResults),
20
);
const rawResults = Array.isArray(response.data.results)
? response.data.results.slice(0, maxResults)
: [];

const organic: t.OrganicResult[] = rawResults.map((result) => ({
title: result.title ?? '',
link: result.url ?? '',
snippet: result.description ?? result.snippet ?? '',
date: result.published_at,
}));

return { success: true, data: { organic } };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Keenable API request failed: ${errorMessage}`,
};
}
};

return { getSources };
};
183 changes: 183 additions & 0 deletions src/tools/search/keenable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import axios from 'axios';
import { createSearchAPI } from './search';
import { createSearchTool } from './tool';
import { DATE_RANGE } from './schema';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

const sampleResponse = {
data: {
results: [
{
title: 'TypeScript Best Practices 2026',
url: 'https://example.com/ts',
description: 'A comprehensive guide to TypeScript.',
published_at: '2026-01-15T10:30:00Z',
},
{
title: 'Second result',
url: 'https://example.com/second',
snippet: 'Snippet fallback when description is absent.',
},
],
},
};

describe('Keenable search API', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.KEENABLE_API_KEY;
delete process.env.KEENABLE_API_URL;
});

it('returns an error for empty queries without calling the API', async () => {
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
const result = await searchAPI.getSources({ query: ' ' });

expect(result).toEqual({ success: false, error: 'Query cannot be empty' });
expect(mockedAxios.post).not.toHaveBeenCalled();
});

it('hits the public endpoint and omits the API key header when keyless', async () => {
mockedAxios.post.mockResolvedValueOnce(sampleResponse);

const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
const result = await searchAPI.getSources({ query: 'typescript' });

expect(mockedAxios.post).toHaveBeenCalledWith(
'https://api.keenable.ai/v1/search/public',
{ query: 'typescript' },
expect.objectContaining({
headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
})
);
const headers = mockedAxios.post.mock.calls[0][2]?.headers as Record<
string,
string
>;
expect(headers['X-API-Key']).toBeUndefined();
expect(result.success).toBe(true);
});

it('hits the authenticated endpoint and sends the API key when a key is set', async () => {
mockedAxios.post.mockResolvedValueOnce(sampleResponse);

const searchAPI = createSearchAPI({
searchProvider: 'keenable',
keenableApiKey: 'secret-key',
});
await searchAPI.getSources({ query: 'typescript' });

expect(mockedAxios.post).toHaveBeenCalledWith(
'https://api.keenable.ai/v1/search',
{ query: 'typescript' },
expect.objectContaining({
headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
})
);
});

it('maps results into organic sources (description and snippet fallback)', async () => {
mockedAxios.post.mockResolvedValueOnce(sampleResponse);

const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
const result = await searchAPI.getSources({ query: 'typescript' });

expect(result.data?.organic).toEqual([
{
title: 'TypeScript Best Practices 2026',
link: 'https://example.com/ts',
snippet: 'A comprehensive guide to TypeScript.',
date: '2026-01-15T10:30:00Z',
},
{
title: 'Second result',
link: 'https://example.com/second',
snippet: 'Snippet fallback when description is absent.',
date: undefined,
},
]);
});

it('applies the site filter and limits results client-side', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
results: [{ url: '1' }, { url: '2' }, { url: '3' }],
},
});

const searchAPI = createSearchAPI({
searchProvider: 'keenable',
keenableSearchOptions: { site: 'github.com', maxResults: 2 },
});
const result = await searchAPI.getSources({ query: 'typescript' });

expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
{ query: 'typescript', site: 'github.com' },
expect.any(Object)
);
expect(result.data?.organic).toHaveLength(2);
});

it('maps date ranges to Keenable published filters', async () => {
mockedAxios.post.mockResolvedValueOnce(sampleResponse);

const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
await searchAPI.getSources({
query: 'typescript',
date: DATE_RANGE.PAST_WEEK,
});

expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
{ query: 'typescript', published_after: '7d' },
expect.any(Object)
);
});

it('surfaces request failures as a structured error', async () => {
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));

const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
const result = await searchAPI.getSources({ query: 'typescript' });

expect(result.success).toBe(false);
expect(result.error).toBe('Keenable API request failed: Network error');
});
});

describe('Keenable capability gating', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.KEENABLE_API_KEY;
delete process.env.KEENABLE_API_URL;
});

it('skips image/news/video sub-searches (organic-only provider)', async () => {
mockedAxios.post.mockResolvedValueOnce(sampleResponse).mockResolvedValue({
data: { success: true, data: { markdown: '# A', html: '<p>a</p>' } },
});

const searchTool = createSearchTool({
searchProvider: 'keenable',
scraperProvider: 'firecrawl',
firecrawlApiKey: 'k',
topResults: 1,
rerankerType: 'none',
});

await searchTool.invoke({
query: 'typescript',
images: true,
news: true,
videos: true,
});

const searchCalls = mockedAxios.post.mock.calls.filter(([url]) =>
(url as string).includes('keenable')
);
expect(searchCalls).toHaveLength(1);
});
});
12 changes: 11 additions & 1 deletion src/tools/search/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios from 'axios';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import type * as t from './types';
import { getAttribution, createDefaultLogger } from './utils';
import { createKeenableAPI } from './keenable-search';
import { createTavilyAPI } from './tavily-search';
import { BaseReranker } from './rerankers';

Expand Down Expand Up @@ -485,6 +486,9 @@ export const createSearchAPI = (
tavilyApiKey,
tavilySearchUrl,
tavilySearchOptions,
keenableApiKey,
keenableApiUrl,
keenableSearchOptions,
} = config;

if (searchProvider.toLowerCase() === 'serper') {
Expand All @@ -493,9 +497,15 @@ export const createSearchAPI = (
return createSearXNGAPI(searxngInstanceUrl, searxngApiKey);
} else if (searchProvider.toLowerCase() === 'tavily') {
return createTavilyAPI(tavilyApiKey, tavilySearchUrl, tavilySearchOptions);
} else if (searchProvider.toLowerCase() === 'keenable') {
return createKeenableAPI(
keenableApiKey,
keenableApiUrl,
keenableSearchOptions
);
} else {
throw new Error(
`Invalid search provider: ${searchProvider}. Must be 'serper', 'searxng', or 'tavily'`
`Invalid search provider: ${searchProvider}. Must be 'serper', 'searxng', 'tavily', or 'keenable'`
);
}
};
Expand Down
21 changes: 18 additions & 3 deletions src/tools/search/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,17 @@ export async function executeParallelSearches({
function createSearchProcessor({
searchAPI,
safeSearch,
supportsImages,
supportsVideos,
supportsNews,
sourceProcessor,
onGetHighlights,
logger,
}: {
safeSearch: t.SearchToolConfig['safeSearch'];
supportsImages: boolean;
supportsVideos: boolean;
supportsNews: boolean;
searchAPI: ReturnType<typeof createSearchAPI>;
sourceProcessor: ReturnType<typeof createSourceProcessor>;
onGetHighlights: t.SearchToolConfig['onGetHighlights'];
Expand Down Expand Up @@ -238,9 +242,9 @@ function createSearchProcessor({
date,
country,
safeSearch,
images,
images: supportsImages && images,
videos: supportsVideos && videos,
news,
news: supportsNews && news,
logger,
});

Expand Down Expand Up @@ -362,6 +366,9 @@ export const createSearchTool = (
tavilySearchUrl,
tavilyExtractUrl,
tavilySearchOptions,
keenableApiKey,
keenableApiUrl,
keenableSearchOptions,
rerankerType = 'cohere',
rerankerTimeout,
topResults = 5,
Expand Down Expand Up @@ -422,6 +429,9 @@ export const createSearchTool = (
tavilyApiKey,
tavilySearchUrl,
tavilySearchOptions: effectiveTavilySearchOptions,
keenableApiKey,
keenableApiUrl,
keenableSearchOptions,
});

/** Create scraper based on scraperProvider */
Expand Down Expand Up @@ -487,7 +497,12 @@ export const createSearchTool = (
const search = createSearchProcessor({
searchAPI,
safeSearch,
supportsVideos: searchProvider !== 'tavily',
// Keenable is organic-only: its API ignores `type`, so image/news
// sub-searches would spend rate limit and merge nothing.
supportsImages: searchProvider !== 'keenable',
supportsVideos:
searchProvider !== 'tavily' && searchProvider !== 'keenable',
supportsNews: searchProvider !== 'keenable',
sourceProcessor,
onGetHighlights,
logger,
Expand Down
Loading
Loading