mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
Merge 8932a3866b into 8eb9de011f
This commit is contained in:
commit
a137f4c18f
8 changed files with 281 additions and 0 deletions
|
|
@ -836,6 +836,11 @@ OPENWEATHER_API_KEY=
|
|||
# Tavily (Search Provider and/or Scraper)
|
||||
# TAVILY_API_KEY=your_tavily_api_key
|
||||
|
||||
# Keenable (Agent Tool — search engine for AI agents)
|
||||
# KEENABLE_API_KEY=your_keenable_api_key
|
||||
# Optional: override the default Keenable API URL
|
||||
# KEENABLE_API_URL=https://api.keenable.ai/v1/search
|
||||
|
||||
# Scraper (Required)
|
||||
# FIRECRAWL_API_KEY=your_firecrawl_api_key
|
||||
# Optional: Custom Firecrawl API URL
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const GoogleSearchAPI = require('./structured/GoogleSearch');
|
|||
const TraversaalSearch = require('./structured/TraversaalSearch');
|
||||
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
|
||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||
const KeenableSearch = require('./structured/KeenableSearch');
|
||||
const createGeminiImageTool = require('./structured/GeminiImageGen');
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -25,6 +26,7 @@ module.exports = {
|
|||
TraversaalSearch,
|
||||
StructuredWolfram,
|
||||
TavilySearchResults,
|
||||
KeenableSearch,
|
||||
createOpenAIImageTools,
|
||||
createGeminiImageTool,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -83,6 +83,19 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Keenable Search",
|
||||
"pluginKey": "keenable_search",
|
||||
"description": "Keenable is a search engine built for AI agents. Useful for answering questions about current events or anything beyond the model's training data.",
|
||||
"icon": "assets/keenable.svg",
|
||||
"authConfig": [
|
||||
{
|
||||
"authField": "KEENABLE_API_KEY",
|
||||
"label": "Keenable API Key",
|
||||
"description": "Get your API key from your Keenable account."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Calculator",
|
||||
"pluginKey": "calculator",
|
||||
|
|
|
|||
96
api/app/clients/tools/structured/KeenableSearch.js
Normal file
96
api/app/clients/tools/structured/KeenableSearch.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const { ProxyAgent, fetch } = require('undici');
|
||||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
|
||||
|
||||
const KEENABLE_DEFAULT_API_URL = 'https://api.keenable.ai/v1/search';
|
||||
|
||||
const keenableSearchJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'The search query string.',
|
||||
},
|
||||
max_results: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
description: 'The maximum number of search results to return. Defaults to 10.',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
};
|
||||
|
||||
class KeenableSearch extends Tool {
|
||||
static lc_name() {
|
||||
return 'KeenableSearch';
|
||||
}
|
||||
|
||||
constructor(fields = {}) {
|
||||
super(fields);
|
||||
this.envVar = 'KEENABLE_API_KEY';
|
||||
this.urlEnvVar = 'KEENABLE_API_URL';
|
||||
/* Used to initialize the Tool without necessary variables. */
|
||||
this.override = fields.override ?? false;
|
||||
this.apiKey = fields[this.envVar] ?? this.getApiKey();
|
||||
this.apiUrl =
|
||||
fields[this.urlEnvVar] ?? getEnvironmentVariable(this.urlEnvVar) ?? KEENABLE_DEFAULT_API_URL;
|
||||
|
||||
this.kwargs = fields?.kwargs ?? {};
|
||||
this.name = 'keenable_search';
|
||||
this.description =
|
||||
"Keenable is a search engine built for AI agents. Returns relevant web pages with titles, URLs, and content snippets. Use it to answer questions about current events, recent information, or anything beyond the model's training data.";
|
||||
|
||||
this.schema = keenableSearchJsonSchema;
|
||||
}
|
||||
|
||||
static get jsonSchema() {
|
||||
return keenableSearchJsonSchema;
|
||||
}
|
||||
|
||||
getApiKey() {
|
||||
const apiKey = getEnvironmentVariable(this.envVar);
|
||||
if (!apiKey && !this.override) {
|
||||
throw new Error(`Missing ${this.envVar} environment variable.`);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async _call(input) {
|
||||
const { query, max_results, ...rest } = input;
|
||||
|
||||
const requestBody = {
|
||||
query,
|
||||
max_results: max_results ?? 10,
|
||||
...rest,
|
||||
...this.kwargs,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiUrl, fetchOptions);
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request failed with status ${response.status}: ${json?.detail?.error || json?.error || JSON.stringify(json)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(json);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = KeenableSearch;
|
||||
131
api/app/clients/tools/structured/specs/KeenableSearch.spec.js
Normal file
131
api/app/clients/tools/structured/specs/KeenableSearch.spec.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
const { fetch, ProxyAgent } = require('undici');
|
||||
const KeenableSearch = require('../KeenableSearch');
|
||||
|
||||
jest.mock('undici');
|
||||
|
||||
describe('KeenableSearch', () => {
|
||||
let originalEnv;
|
||||
const mockApiKey = 'mock_api_key';
|
||||
|
||||
beforeAll(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
KEENABLE_API_KEY: mockApiKey,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should throw an error if KEENABLE_API_KEY is missing', () => {
|
||||
delete process.env.KEENABLE_API_KEY;
|
||||
expect(() => new KeenableSearch()).toThrow('Missing KEENABLE_API_KEY environment variable.');
|
||||
});
|
||||
|
||||
it('should use provided KEENABLE_API_KEY field when env var is unset', () => {
|
||||
delete process.env.KEENABLE_API_KEY;
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
expect(instance.apiKey).toBe(mockApiKey);
|
||||
});
|
||||
|
||||
it('should default to https://api.keenable.ai/v1/search when KEENABLE_API_URL is unset', () => {
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
expect(instance.apiUrl).toBe('https://api.keenable.ai/v1/search');
|
||||
});
|
||||
|
||||
it('should honor KEENABLE_API_URL override', () => {
|
||||
process.env.KEENABLE_API_URL = 'https://staging.keenable.ai/search';
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
expect(instance.apiUrl).toBe('https://staging.keenable.ai/search');
|
||||
});
|
||||
|
||||
describe('_call', () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ results: [] }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.mockResolvedValue(mockResponse);
|
||||
});
|
||||
|
||||
it('should send a POST with X-API-Key auth and the query body', async () => {
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
await instance._call({ query: 'test query', max_results: 3 });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.keenable.ai/v1/search',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': mockApiKey,
|
||||
}),
|
||||
body: JSON.stringify({ query: 'test query', max_results: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default max_results to 10 when not provided', async () => {
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
await instance._call({ query: 'test query' });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.keenable.ai/v1/search',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ query: 'test query', max_results: 10 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use ProxyAgent when PROXY env var is set', async () => {
|
||||
const proxyUrl = 'http://proxy.example.com:8080';
|
||||
process.env.PROXY = proxyUrl;
|
||||
|
||||
const mockProxyAgent = { type: 'proxy-agent' };
|
||||
ProxyAgent.mockImplementation(() => mockProxyAgent);
|
||||
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
await instance._call({ query: 'test query' });
|
||||
|
||||
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.keenable.ai/v1/search',
|
||||
expect.objectContaining({ dispatcher: mockProxyAgent }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not use ProxyAgent when PROXY env var is not set', async () => {
|
||||
delete process.env.PROXY;
|
||||
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
await instance._call({ query: 'test query' });
|
||||
|
||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.keenable.ai/v1/search',
|
||||
expect.not.objectContaining({ dispatcher: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-2xx response', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: jest.fn().mockResolvedValue({ error: 'invalid key' }),
|
||||
});
|
||||
|
||||
const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
|
||||
await expect(instance._call({ query: 'test' })).rejects.toThrow(
|
||||
'Request failed with status 401: invalid key',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ const {
|
|||
TraversaalSearch,
|
||||
StructuredWolfram,
|
||||
TavilySearchResults,
|
||||
KeenableSearch,
|
||||
createGeminiImageTool,
|
||||
createOpenAIImageTools,
|
||||
} = require('../');
|
||||
|
|
@ -179,6 +180,7 @@ const loadTools = async ({
|
|||
'azure-ai-search': StructuredACS,
|
||||
traversaal_search: TraversaalSearch,
|
||||
tavily_search_results_json: TavilySearchResults,
|
||||
keenable_search: KeenableSearch,
|
||||
};
|
||||
|
||||
const customConstructors = {
|
||||
|
|
|
|||
6
client/public/assets/keenable.svg
Normal file
6
client/public/assets/keenable.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="73.2769" cy="96.9181" r="10.5025" fill="#252735"/>
|
||||
<path d="M60.5452 63.0005C67.0854 63.0005 73.0333 64.6827 78.3887 68.0475C83.7916 71.4124 88.0808 75.9389 91.2562 81.6261C94.479 87.3133 96.0904 93.6168 96.0904 100.536C96.0904 102.9 95.8946 105.195 95.5075 107.42H83.2844C83.858 105.216 84.1477 102.922 84.1477 100.536C84.1477 95.8917 83.0813 91.626 80.9486 87.7397C78.8632 83.8535 76.0432 80.7492 72.4888 78.4269C68.9342 76.1046 64.9528 74.9432 60.5452 74.9432C56.0902 74.9432 52.0852 76.1282 48.5307 78.4979C44.9763 80.8201 42.1562 83.9245 40.0709 87.8107C37.9856 91.697 36.9436 95.9391 36.9436 100.536C36.9436 105.276 38.01 109.588 40.1427 113.475C42.2754 117.314 45.1425 120.371 48.7444 122.646C52.3462 124.873 56.2799 125.987 60.5452 125.987C65.0001 125.987 69.0052 124.826 72.5597 122.504C74.872 120.983 76.8595 119.156 78.5272 117.029L86.5126 126.023C86.2269 126.346 85.9375 126.668 85.64 126.982C82.4173 130.395 78.6493 133.073 74.3366 135.016C70.0713 136.959 65.474 137.93 60.5452 137.93C54.0049 137.93 48.0336 136.247 42.6307 132.882C37.2753 129.517 32.986 125.015 29.7632 119.376C26.5878 113.688 25 107.408 25 100.536C25 95.323 25.9243 90.4652 27.7726 85.9628C29.621 81.413 32.1567 77.4316 35.3795 74.0193C38.6496 70.5596 42.441 67.8581 46.7538 65.915C51.0666 63.9719 55.6637 63.0005 60.5452 63.0005Z" fill="#005CFF"/>
|
||||
<circle cx="152.187" cy="96.9181" r="10.5025" fill="#252735"/>
|
||||
<path d="M139.454 63C145.995 63 151.943 64.6822 157.298 68.047C162.701 71.4119 166.99 75.9384 170.165 81.6256C173.388 87.3128 175 93.6163 175 100.536C175 102.9 174.804 105.194 174.417 107.42H162.194C162.767 105.216 163.057 102.922 163.057 100.536C163.057 95.8912 161.99 91.6255 159.858 87.7392C157.772 83.853 154.952 80.7487 151.398 78.4265C147.843 76.1042 143.862 74.9427 139.454 74.9427C134.999 74.9427 130.994 76.1277 127.44 78.4974C123.885 80.8197 121.065 83.924 118.98 87.8102C116.895 91.6965 115.853 95.9386 115.853 100.536C115.853 105.275 116.919 109.588 119.052 113.474C121.185 117.313 124.052 120.37 127.654 122.645C131.255 124.873 135.189 125.986 139.454 125.986C143.909 125.986 147.914 124.825 151.469 122.503C153.781 120.982 155.769 119.156 157.436 117.029L165.422 126.022C165.136 126.345 164.847 126.667 164.549 126.982C161.326 130.394 157.558 133.072 153.246 135.015C148.98 136.958 144.383 137.93 139.454 137.93C132.914 137.93 126.943 136.247 121.54 132.882C116.184 129.517 111.895 125.015 108.672 119.375C105.497 113.688 103.909 107.408 103.909 100.536C103.909 95.3225 104.833 90.4647 106.682 85.9623C108.53 81.4125 111.066 77.4311 114.289 74.0188C117.559 70.5591 121.35 67.8576 125.663 65.9145C129.976 63.9714 134.573 63 139.454 63Z" fill="#005CFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -337,6 +337,25 @@ export const tavilySearchSchema: ExtendedJsonSchema = {
|
|||
required: ['query'],
|
||||
};
|
||||
|
||||
/** Keenable Search tool JSON schema */
|
||||
export const keenableSearchSchema: ExtendedJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'The search query string.',
|
||||
},
|
||||
max_results: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
description: 'The maximum number of search results to return. Defaults to 10.',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
};
|
||||
|
||||
/** File Search tool JSON schema */
|
||||
export const fileSearchSchema: ExtendedJsonSchema = {
|
||||
type: 'object',
|
||||
|
|
@ -416,6 +435,13 @@ export const toolDefinitions: Record<string, ToolRegistryDefinition> = {
|
|||
schema: tavilySearchSchema,
|
||||
toolType: 'builtin',
|
||||
},
|
||||
keenable_search: {
|
||||
name: 'keenable_search',
|
||||
description:
|
||||
"Keenable is a search engine built for AI agents. Returns relevant web pages with titles, URLs, and content snippets. Useful for answering questions about current events or anything beyond the model's training data.",
|
||||
schema: keenableSearchSchema,
|
||||
toolType: 'builtin',
|
||||
},
|
||||
file_search: {
|
||||
name: 'file_search',
|
||||
description:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue