From d08baf4da9dbc3925b4fa8a78c34d902eb3a4f9b Mon Sep 17 00:00:00 2001 From: Andrey Styskin Date: Tue, 12 May 2026 15:14:21 -0700 Subject: [PATCH 1/2] feat: add Keenable Search agent tool Registers Keenable Search as a built-in agent tool, mirroring the Tavily integration end to end: - New tool class `KeenableSearch` (POST with X-API-Key header) - Manifest entry + plugin auth config for KEENABLE_API_KEY - Tool registry + schema in packages/api - Brand icon at client/public/assets/keenable.svg - Unit tests covering auth flow, default URL, proxy support, and non-2xx error handling - .env.example documents KEENABLE_API_KEY and KEENABLE_API_URL The default endpoint is https://api.keenable.ai/v1/search and can be overridden via KEENABLE_API_URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 5 + api/app/clients/tools/index.js | 2 + api/app/clients/tools/manifest.json | 13 ++ .../tools/structured/KeenableSearch.js | 105 ++++++++++++++++ .../structured/specs/KeenableSearch.spec.js | 119 ++++++++++++++++++ api/app/clients/tools/util/handleTools.js | 2 + client/public/assets/keenable.svg | 6 + .../api/src/tools/registry/definitions.ts | 36 ++++++ 8 files changed, 288 insertions(+) create mode 100644 api/app/clients/tools/structured/KeenableSearch.js create mode 100644 api/app/clients/tools/structured/specs/KeenableSearch.spec.js create mode 100644 client/public/assets/keenable.svg diff --git a/.env.example b/.env.example index 5d7f69a9de..acdcc79141 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index bb58e81221..f021799846 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -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, }; diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 9637c20867..29c84178af 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -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", diff --git a/api/app/clients/tools/structured/KeenableSearch.js b/api/app/clients/tools/structured/KeenableSearch.js new file mode 100644 index 0000000000..0a76fe493a --- /dev/null +++ b/api/app/clients/tools/structured/KeenableSearch.js @@ -0,0 +1,105 @@ +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 5.', + }, + include_domains: { + type: 'array', + items: { type: 'string' }, + description: 'A list of domains to specifically include in the search results.', + }, + exclude_domains: { + type: 'array', + items: { type: 'string' }, + description: 'A list of domains to specifically exclude from the search results.', + }, + }, + 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, ...rest } = input; + + const requestBody = { + query, + ...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; diff --git a/api/app/clients/tools/structured/specs/KeenableSearch.spec.js b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js new file mode 100644 index 0000000000..86201d22be --- /dev/null +++ b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js @@ -0,0 +1,119 @@ +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 Bearer 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 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', + ); + }); + }); +}); diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 89a79f3cbd..a963107de2 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -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 = { diff --git a/client/public/assets/keenable.svg b/client/public/assets/keenable.svg new file mode 100644 index 0000000000..e11d13d482 --- /dev/null +++ b/client/public/assets/keenable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 5e953ce547..4715c5ce4e 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -337,6 +337,35 @@ 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 5.', + }, + include_domains: { + type: 'array', + items: { type: 'string' }, + description: 'A list of domains to specifically include in the search results.', + }, + exclude_domains: { + type: 'array', + items: { type: 'string' }, + description: 'A list of domains to specifically exclude from the search results.', + }, + }, + required: ['query'], +}; + /** File Search tool JSON schema */ export const fileSearchSchema: ExtendedJsonSchema = { type: 'object', @@ -416,6 +445,13 @@ export const toolDefinitions: Record = { 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: From 8932a3866b594e6fe68821bde0a349162f3c0ed8 Mon Sep 17 00:00:00 2001 From: Andrey Styskin Date: Tue, 12 May 2026 15:27:31 -0700 Subject: [PATCH 2/2] fix(keenable): drop unsupported domain filters, default max_results to 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keenable's search API doesn't support include_domains/exclude_domains inputs — remove them from the schema so the model doesn't fabricate unrecognized fields. Also bump the default page size from 5 to 10 to match expected Keenable behavior, and enforce it server-side when the model omits the parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../clients/tools/structured/KeenableSearch.js | 15 +++------------ .../tools/structured/specs/KeenableSearch.spec.js | 14 +++++++++++++- packages/api/src/tools/registry/definitions.ts | 12 +----------- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/api/app/clients/tools/structured/KeenableSearch.js b/api/app/clients/tools/structured/KeenableSearch.js index 0a76fe493a..5d6ce5ace0 100644 --- a/api/app/clients/tools/structured/KeenableSearch.js +++ b/api/app/clients/tools/structured/KeenableSearch.js @@ -16,17 +16,7 @@ const keenableSearchJsonSchema = { type: 'number', minimum: 1, maximum: 20, - description: 'The maximum number of search results to return. Defaults to 5.', - }, - include_domains: { - type: 'array', - items: { type: 'string' }, - description: 'A list of domains to specifically include in the search results.', - }, - exclude_domains: { - type: 'array', - items: { type: 'string' }, - description: 'A list of domains to specifically exclude from the search results.', + description: 'The maximum number of search results to return. Defaults to 10.', }, }, required: ['query'], @@ -68,10 +58,11 @@ class KeenableSearch extends Tool { } async _call(input) { - const { query, ...rest } = input; + const { query, max_results, ...rest } = input; const requestBody = { query, + max_results: max_results ?? 10, ...rest, ...this.kwargs, }; diff --git a/api/app/clients/tools/structured/specs/KeenableSearch.spec.js b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js index 86201d22be..b3b08006b6 100644 --- a/api/app/clients/tools/structured/specs/KeenableSearch.spec.js +++ b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js @@ -56,7 +56,7 @@ describe('KeenableSearch', () => { fetch.mockResolvedValue(mockResponse); }); - it('should send a POST with Bearer auth and the query body', async () => { + 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 }); @@ -73,6 +73,18 @@ describe('KeenableSearch', () => { ); }); + 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; diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 4715c5ce4e..942073df4e 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -350,17 +350,7 @@ export const keenableSearchSchema: ExtendedJsonSchema = { type: 'number', minimum: 1, maximum: 20, - description: 'The maximum number of search results to return. Defaults to 5.', - }, - include_domains: { - type: 'array', - items: { type: 'string' }, - description: 'A list of domains to specifically include in the search results.', - }, - exclude_domains: { - type: 'array', - items: { type: 'string' }, - description: 'A list of domains to specifically exclude from the search results.', + description: 'The maximum number of search results to return. Defaults to 10.', }, }, required: ['query'],