From a64babd3e9bc546377eab0c704a14aa2bf97e470 Mon Sep 17 00:00:00 2001 From: KGKallasmaa Date: Tue, 3 Nov 2026 16:31:01 +0100 Subject: [PATCH] feat(web-search): add Keenable as an external search backend Signed-off-by: KGKallasmaa --- src/tools/WebSearchTool/WebSearchTool.ts | 10 ++-- src/tools/WebSearchTool/backend.test.ts | 18 +++++++ src/tools/WebSearchTool/backend.ts | 63 ++++++++++++++++++++++-- src/utils/settings/types.ts | 8 ++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/tools/WebSearchTool/WebSearchTool.ts b/src/tools/WebSearchTool/WebSearchTool.ts index a49742ff..d582bb35 100644 --- a/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/tools/WebSearchTool/WebSearchTool.ts @@ -254,12 +254,16 @@ export const WebSearchTool = buildTool({ data: makeWebSearchUnavailableOutput( query, durationSeconds, - 'Web search is not configured for this model. Use a Claude model for native web search or add a Tavily/Brave API key in Settings.', + 'Web search is not configured for this model. Use a Claude model for native web search or add a Tavily/Brave/Keenable API key in Settings.', ), } } - if (resolved.provider === 'tavily' || resolved.provider === 'brave') { + if ( + resolved.provider === 'tavily' || + resolved.provider === 'brave' || + resolved.provider === 'keenable' + ) { onProgress?.({ toolUseID: `${resolved.provider}-web-search`, data: { @@ -317,7 +321,7 @@ export const WebSearchTool = buildTool({ data: makeWebSearchUnavailableOutput( query, durationSeconds, - 'Native Anthropic web search failed for this endpoint, and no Tavily/Brave API key is configured for fallback.', + 'Native Anthropic web search failed for this endpoint, and no Tavily/Brave/Keenable API key is configured for fallback.', ), } } diff --git a/src/tools/WebSearchTool/backend.test.ts b/src/tools/WebSearchTool/backend.test.ts index 772a3b5f..cd9d7fe1 100644 --- a/src/tools/WebSearchTool/backend.test.ts +++ b/src/tools/WebSearchTool/backend.test.ts @@ -51,6 +51,24 @@ describe('WebSearch backend resolver', () => { braveApiKey: 'brave-key', }).provider, ).toBe('brave') + expect(resolveWebSearchProvider('gpt-5.4', { mode: 'keenable' }).provider).toBe( + 'disabled', + ) + expect( + resolveWebSearchProvider('gpt-5.4', { + mode: 'keenable', + keenableApiKey: 'keenable-key', + }).provider, + ).toBe('keenable') + }) + + test('auto mode falls back to Keenable when it is the only key', () => { + expect( + resolveWebSearchProvider('gpt-5.4', { + mode: 'auto', + keenableApiKey: 'keenable-key', + }).provider, + ).toBe('keenable') }) test('isEnabled reflects native Claude or external fallback availability', () => { diff --git a/src/tools/WebSearchTool/backend.ts b/src/tools/WebSearchTool/backend.ts index 281b8bd5..5411801c 100644 --- a/src/tools/WebSearchTool/backend.ts +++ b/src/tools/WebSearchTool/backend.ts @@ -7,14 +7,16 @@ export type WebSearchMode = | 'anthropic' | 'tavily' | 'brave' + | 'keenable' | 'disabled' -export type WebSearchProvider = 'anthropic' | 'tavily' | 'brave' | 'disabled' +export type WebSearchProvider = 'anthropic' | 'tavily' | 'brave' | 'keenable' | 'disabled' export type WebSearchSettings = { mode?: WebSearchMode tavilyApiKey?: string braveApiKey?: string + keenableApiKey?: string } export type ResolvedWebSearch = { @@ -32,6 +34,7 @@ const WEB_SEARCH_MODES = new Set([ 'anthropic', 'tavily', 'brave', + 'keenable', 'disabled', ]) @@ -59,6 +62,7 @@ export function getConfiguredWebSearchSettings( mode: WEB_SEARCH_MODES.has(modeCandidate) ? modeCandidate : 'auto', tavilyApiKey: normalizeApiKey(raw.tavilyApiKey), braveApiKey: normalizeApiKey(raw.braveApiKey), + keenableApiKey: normalizeApiKey(raw.keenableApiKey), } } @@ -80,6 +84,10 @@ export function resolveWebSearchProvider( return { provider: settings.braveApiKey ? 'brave' : 'disabled', settings } } + if (mode === 'keenable') { + return { provider: settings.keenableApiKey ? 'keenable' : 'disabled', settings } + } + if (mode === 'anthropic') { return { provider: canUseAnthropicNativeWebSearch(model) ? 'anthropic' : 'disabled', @@ -99,6 +107,10 @@ export function resolveWebSearchProvider( return { provider: 'brave', settings } } + if (settings.keenableApiKey) { + return { provider: 'keenable', settings } + } + return { provider: 'disabled', settings } } @@ -136,7 +148,9 @@ export async function searchWithExternalProvider( const hits = provider === 'tavily' ? await searchWithTavily(input, apiKey, signal) - : await searchWithBrave(input, apiKey, signal) + : provider === 'keenable' + ? await searchWithKeenable(input, apiKey, signal) + : await searchWithBrave(input, apiKey, signal) const durationSeconds = (performance.now() - startTime) / 1000 return makeExternalSearchOutput(provider, input.query, hits, durationSeconds) @@ -151,6 +165,9 @@ export function getFallbackProvider( if (settings.braveApiKey) { return 'brave' } + if (settings.keenableApiKey) { + return 'keenable' + } return null } @@ -158,9 +175,13 @@ export function getApiKeyForProvider( provider: Exclude, settings: WebSearchSettings, ): string | null { - return provider === 'tavily' - ? settings.tavilyApiKey ?? null - : settings.braveApiKey ?? null + if (provider === 'tavily') { + return settings.tavilyApiKey ?? null + } + if (provider === 'keenable') { + return settings.keenableApiKey ?? null + } + return settings.braveApiKey ?? null } export function makeWebSearchUnavailableOutput( @@ -258,6 +279,38 @@ async function searchWithBrave( .filter((hit): hit is ExternalSearchHit => hit != null) } +async function searchWithKeenable( + input: Input, + apiKey: string, + signal: AbortSignal, +): Promise { + const response = await fetch('https://api.keenable.ai/v1/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'X-Keenable-Title': 'DreamCoder', + }, + body: JSON.stringify({ + query: applyDomainFiltersToQuery(input), + max_results: 8, + }), + signal, + }) + + if (!response.ok) { + throw new Error(`Keenable search failed: ${response.status} ${await readErrorBody(response)}`) + } + + const body = (await response.json()) as { + results?: Array<{ title?: unknown; url?: unknown }> + } + + return (body.results ?? []) + .map(hit => normalizeHit(hit.title, hit.url)) + .filter((hit): hit is ExternalSearchHit => hit != null) +} + function applyDomainFiltersToQuery(input: Input): string { const allowed = input.allowed_domains?.filter(Boolean) ?? [] const blocked = input.blocked_domains?.filter(Boolean) ?? [] diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 006b8ffd..8db0fc0a 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -655,10 +655,10 @@ export const SettingsSchema = lazySchema(() => webSearch: z .object({ mode: z - .enum(['auto', 'anthropic', 'tavily', 'brave', 'disabled']) + .enum(['auto', 'anthropic', 'tavily', 'brave', 'keenable', 'disabled']) .optional() .describe( - 'WebSearch backend selection. auto uses native Claude web search for Claude model names, then Tavily, then Brave.', + 'WebSearch backend selection. auto uses native Claude web search for Claude model names, then Tavily, then Brave, then Keenable.', ), tavilyApiKey: z .string() @@ -668,6 +668,10 @@ export const SettingsSchema = lazySchema(() => .string() .optional() .describe('Brave Search API key for WebSearch fallback'), + keenableApiKey: z + .string() + .optional() + .describe('Keenable API key for WebSearch fallback'), }) .optional() .describe('Configures native and external WebSearch backends'),