Skip to content
Open
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
10 changes: 7 additions & 3 deletions src/tools/WebSearchTool/WebSearchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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.',
),
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/tools/WebSearchTool/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
63 changes: 58 additions & 5 deletions src/tools/WebSearchTool/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -32,6 +34,7 @@ const WEB_SEARCH_MODES = new Set<WebSearchMode>([
'anthropic',
'tavily',
'brave',
'keenable',
'disabled',
])

Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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',
Expand All @@ -99,6 +107,10 @@ export function resolveWebSearchProvider(
return { provider: 'brave', settings }
}

if (settings.keenableApiKey) {
return { provider: 'keenable', settings }
}

return { provider: 'disabled', settings }
}

Expand Down Expand Up @@ -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)
Expand All @@ -151,16 +165,23 @@ export function getFallbackProvider(
if (settings.braveApiKey) {
return 'brave'
}
if (settings.keenableApiKey) {
return 'keenable'
}
return null
}

export function getApiKeyForProvider(
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
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(
Expand Down Expand Up @@ -258,6 +279,38 @@ async function searchWithBrave(
.filter((hit): hit is ExternalSearchHit => hit != null)
}

async function searchWithKeenable(
input: Input,
apiKey: string,
signal: AbortSignal,
): Promise<ExternalSearchHit[]> {
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) ?? []
Expand Down
8 changes: 6 additions & 2 deletions src/utils/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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'),
Expand Down