From c6a1804e476720ff128cc74795e6c14f8b401e58 Mon Sep 17 00:00:00 2001 From: Name_TUT Date: Mon, 24 Aug 2026 20:31:27 +0300 Subject: [PATCH] feat(web-search): add Tavily provider --- src/config/config-schema.ts | 21 ++++- .../web-search/providers/provider-registry.ts | 3 + .../providers/search-orchestrator.ts | 4 + .../web-search/providers/tavily-provider.ts | 80 +++++++++++++++++++ .../tool/warn-missing-search-key.ts | 8 +- .../os/web-search/web-search-provider.ts | 2 +- 6 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 src/tools/os/web-search/providers/tavily-provider.ts diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index ae3bddc6..6e64ebee 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -33,7 +33,7 @@ export type LogLevel = "debug" | "info" | "warn" | "error"; export type BrowserChannel = "chrome" | "msedge" | "chromium"; -export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave"; +export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave" | "tavily"; /** * Tunables for `os.web.fetch` (config v38). Before v38 the tool hard-coded a @@ -109,6 +109,9 @@ export interface WebSearchConfig { brave: { apiKeyEnv: string; }; + tavily: { + apiKeyEnv: string; + }; } /** @@ -1750,6 +1753,9 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { brave: { apiKeyEnv: "BRAVE_SEARCH_API_KEY", }, + tavily: { + apiKeyEnv: "TAVILY_API_KEY", + }, }, fetch: { timeoutMs: 30_000, @@ -2073,12 +2079,12 @@ export function parseWebSearchProviderName( raw: unknown, field: string, ): WebSearchProviderName { - if (raw === "duckduckgo" || raw === "searxng" || raw === "exa" || raw === "brave") { + if (raw === "duckduckgo" || raw === "searxng" || raw === "exa" || raw === "brave" || raw === "tavily") { return raw; } throw new ConfigValidationError( field, - `expected one of duckduckgo|searxng|exa|brave, got ${JSON.stringify(raw)}`, + `expected one of duckduckgo|searxng|exa|brave|tavily, got ${JSON.stringify(raw)}`, ); } @@ -2923,6 +2929,8 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { (webSearch.exa as Record | undefined) ?? {}; const webSearchBrave = (webSearch.brave as Record | undefined) ?? {}; + const webSearchTavily = + (webSearch.tavily as Record | undefined) ?? {}; const legacyTelemetry = (obj.telemetry as Record | undefined) ?? {}; const tracing = (obj.tracing as Record | undefined) ?? {}; @@ -3210,6 +3218,13 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { "web.search.brave.apiKeyEnv", ), }, + tavily: { + apiKeyEnv: parseNonEmptyString( + webSearchTavily.apiKeyEnv ?? + USER_CONFIG_DEFAULTS.web.search.tavily.apiKeyEnv, + "web.search.tavily.apiKeyEnv", + ), + }, }, fetch: { timeoutMs: parsePositiveInt( diff --git a/src/tools/os/web-search/providers/provider-registry.ts b/src/tools/os/web-search/providers/provider-registry.ts index 6afad77a..7ba16381 100644 --- a/src/tools/os/web-search/providers/provider-registry.ts +++ b/src/tools/os/web-search/providers/provider-registry.ts @@ -3,6 +3,7 @@ import { createBraveProvider } from "./brave-provider.js"; import { createDuckDuckGoProvider } from "./duckduckgo-provider.js"; import { createExaProvider } from "./exa-provider.js"; import { createSearxngProvider } from "./searxng-provider.js"; +import { createTavilyProvider } from "./tavily-provider.js"; import type { WebSearchHttpDeps, WebSearchProvider, @@ -35,5 +36,7 @@ export function resolveProviderByName( return createExaProvider(search.exa, deps); case "brave": return createBraveProvider(search.brave, deps); + case "tavily": + return createTavilyProvider(search.tavily, deps); } } diff --git a/src/tools/os/web-search/providers/search-orchestrator.ts b/src/tools/os/web-search/providers/search-orchestrator.ts index 80b9c505..4264f25f 100644 --- a/src/tools/os/web-search/providers/search-orchestrator.ts +++ b/src/tools/os/web-search/providers/search-orchestrator.ts @@ -117,6 +117,10 @@ function isProviderUsable( const key = env[search.brave.apiKeyEnv]; return typeof key === "string" && key.length > 0; } + case "tavily": { + const key = env[search.tavily.apiKeyEnv]; + return typeof key === "string" && key.length > 0; + } case "duckduckgo": case "exa": return true; diff --git a/src/tools/os/web-search/providers/tavily-provider.ts b/src/tools/os/web-search/providers/tavily-provider.ts new file mode 100644 index 00000000..07d679b7 --- /dev/null +++ b/src/tools/os/web-search/providers/tavily-provider.ts @@ -0,0 +1,80 @@ +import { searchHttp } from "../transport/search-http.js"; +import type { + WebSearchHttpDeps, + WebSearchProvider, + WebSearchResult, +} from "../web-search-provider.js"; + +const TAVILY_SEARCH_URL = "https://api.tavily.com/search"; + +export interface TavilyProviderConfig { + apiKeyEnv: string; +} + +interface TavilyResult { + title?: unknown; + url?: unknown; + content?: unknown; + publishedDate?: unknown; +} + +export function createTavilyProvider( + config: TavilyProviderConfig, + deps: WebSearchHttpDeps = {}, +): WebSearchProvider { + return { + name: "tavily", + async search(options) { + const apiKey = process.env[config.apiKeyEnv]?.trim(); + if (!apiKey) { + throw new Error( + `Tavily search requires ${config.apiKeyEnv} in the process environment.`, + ); + } + const response = await searchHttp({ + url: TAVILY_SEARCH_URL, + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + api_key: apiKey, + query: options.query, + max_results: options.maxResults, + }), + timeoutMs: options.timeoutMs, + cwd: options.cwd, + signal: options.signal, + runCommand: deps.runCommand, + lookup: deps.lookup, + }); + if (response.status >= 400) { + throw new Error(`Tavily search returned HTTP ${response.status}`); + } + return parseTavilyJson(response.body, options.maxResults); + }, + }; +} + +export function parseTavilyJson( + body: string, + maxResults: number, +): WebSearchResult[] { + const parsed = JSON.parse(body) as { results?: unknown }; + if (!Array.isArray(parsed.results)) return []; + const results: WebSearchResult[] = []; + for (const raw of parsed.results as TavilyResult[]) { + if (typeof raw.title !== "string" || typeof raw.url !== "string") continue; + results.push({ + title: raw.title.trim(), + url: raw.url.trim(), + snippet: typeof raw.content === "string" ? raw.content.trim() : "", + ...(typeof raw.publishedDate === "string" + ? { published: raw.publishedDate.trim() } + : {}), + }); + if (results.length >= maxResults) break; + } + return results; +} \ No newline at end of file diff --git a/src/tools/os/web-search/tool/warn-missing-search-key.ts b/src/tools/os/web-search/tool/warn-missing-search-key.ts index d7a308b7..d7c05f2e 100644 --- a/src/tools/os/web-search/tool/warn-missing-search-key.ts +++ b/src/tools/os/web-search/tool/warn-missing-search-key.ts @@ -13,7 +13,7 @@ import type { WebSearchProviderName } from "../web-search-provider.js"; */ /** Providers whose configured `apiKeyEnv` materially changes their quota. */ -const KEYED_PROVIDERS = new Set(["exa", "brave"]); +const KEYED_PROVIDERS = new Set(["exa", "brave", "tavily"]); export interface MissingSearchKeyWarning { provider: WebSearchProviderName; @@ -40,7 +40,11 @@ export function checkMissingSearchKey(input: { if (!KEYED_PROVIDERS.has(provider)) return null; const apiKeyEnv = - provider === "exa" ? search.exa.apiKeyEnv : search.brave.apiKeyEnv; + provider === "exa" + ? search.exa.apiKeyEnv + : provider === "brave" + ? search.brave.apiKeyEnv + : search.tavily.apiKeyEnv; const key = input.env[apiKeyEnv]?.trim(); if (typeof key === "string" && key.length > 0) return null; diff --git a/src/tools/os/web-search/web-search-provider.ts b/src/tools/os/web-search/web-search-provider.ts index c49c5516..338f250c 100644 --- a/src/tools/os/web-search/web-search-provider.ts +++ b/src/tools/os/web-search/web-search-provider.ts @@ -1,7 +1,7 @@ import type { runCommand as defaultRunCommand } from "../../../sandbox/command-runner.js"; import type { HostLookup } from "../web-fetch-ssrf-guard.js"; -export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave"; +export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave" | "tavily"; export interface WebSearchResult { title: string;