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
21 changes: 18 additions & 3 deletions src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +109,9 @@ export interface WebSearchConfig {
brave: {
apiKeyEnv: string;
};
tavily: {
apiKeyEnv: string;
};
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`,
);
}

Expand Down Expand Up @@ -2923,6 +2929,8 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
(webSearch.exa as Record<string, unknown> | undefined) ?? {};
const webSearchBrave =
(webSearch.brave as Record<string, unknown> | undefined) ?? {};
const webSearchTavily =
(webSearch.tavily as Record<string, unknown> | undefined) ?? {};
const legacyTelemetry =
(obj.telemetry as Record<string, unknown> | undefined) ?? {};
const tracing = (obj.tracing as Record<string, unknown> | undefined) ?? {};
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/tools/os/web-search/providers/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions src/tools/os/web-search/providers/search-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions src/tools/os/web-search/providers/tavily-provider.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 6 additions & 2 deletions src/tools/os/web-search/tool/warn-missing-search-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSearchProviderName>(["exa", "brave"]);
const KEYED_PROVIDERS = new Set<WebSearchProviderName>(["exa", "brave", "tavily"]);

export interface MissingSearchKeyWarning {
provider: WebSearchProviderName;
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/tools/os/web-search/web-search-provider.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down