Add Tavily external search plugin

This commit is contained in:
Tak Hoffman
2026-03-10 16:28:34 -05:00
parent 3396e21d79
commit 667cc46f01
5 changed files with 328 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
function createApi(params?: {
config?: OpenClawPluginApi["config"];
pluginConfig?: Record<string, unknown>;
}) {
let registeredProvider: Parameters<OpenClawPluginApi["registerSearchProvider"]>[0] | undefined;
const api = {
id: "tavily-search",
name: "Tavily Search",
source: "/tmp/tavily-search/index.ts",
config: params?.config ?? {},
pluginConfig: params?.pluginConfig,
runtime: {} as never,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
registerSearchProvider: vi.fn((provider) => {
registeredProvider = provider;
}),
} as unknown as OpenClawPluginApi;
plugin.register?.(api);
if (!registeredProvider) {
throw new Error("search provider was not registered");
}
return registeredProvider;
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("tavily-search plugin", () => {
it("registers a tavily search provider and detects availability from plugin config", () => {
const provider = createApi({
config: {
plugins: {
entries: {
"tavily-search": {
config: {
apiKey: "tvly-test-key",
},
},
},
},
},
});
expect(provider.id).toBe("tavily");
expect(provider.isAvailable?.({})).toBe(false);
expect(
provider.isAvailable?.({
plugins: {
entries: {
"tavily-search": {
config: {
apiKey: "tvly-test-key",
},
},
},
},
}),
).toBe(true);
});
it("maps Tavily responses into plugin search results", async () => {
const provider = createApi({
pluginConfig: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
});
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
answer: "Tavily says hello",
results: [
{
title: "Example",
url: "https://example.com/article",
content: "Snippet",
published_date: "2026-03-10",
},
],
}),
}));
vi.stubGlobal("fetch", fetchMock);
const result = await provider.search(
{
query: "hello",
count: 3,
country: "US",
freshness: "week",
},
{
config: {},
timeoutSeconds: 5,
cacheTtlMs: 1000,
pluginConfig: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
},
);
expect(fetchMock).toHaveBeenCalled();
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toMatchObject({
api_key: "tvly-test-key",
query: "hello",
max_results: 3,
search_depth: "advanced",
topic: "news",
days: 7,
country: "US",
});
expect(result).toEqual({
content: "Tavily says hello",
citations: ["https://example.com/article"],
results: [
{
title: "Example",
url: "https://example.com/article",
description: "Snippet",
published: "2026-03-10",
},
],
});
});
});

View File

@@ -0,0 +1,155 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
type TavilyPluginConfig = {
apiKey?: string;
searchDepth?: "basic" | "advanced";
};
type TavilySearchResult = {
title?: string;
url?: string;
content?: string;
published_date?: string;
};
type TavilySearchResponse = {
answer?: string;
results?: TavilySearchResult[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolvePluginConfig(value: unknown): TavilyPluginConfig {
if (!isRecord(value)) {
return {};
}
return value as TavilyPluginConfig;
}
function resolveRootPluginConfig(
config: OpenClawPluginApi["config"],
pluginId: string,
): TavilyPluginConfig {
return resolvePluginConfig(config?.plugins?.entries?.[pluginId]?.config);
}
function resolveApiKey(config: TavilyPluginConfig): string | undefined {
return normalizeString(config.apiKey) ?? normalizeString(process.env.TAVILY_API_KEY);
}
function resolveSearchDepth(config: TavilyPluginConfig): "basic" | "advanced" {
return config.searchDepth === "advanced" ? "advanced" : "basic";
}
function resolveFreshnessDays(freshness?: string): number | undefined {
const normalized = normalizeString(freshness)?.toLowerCase();
if (normalized === "day") {
return 1;
}
if (normalized === "week") {
return 7;
}
if (normalized === "month") {
return 30;
}
if (normalized === "year") {
return 365;
}
return undefined;
}
const plugin = {
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily web_search provider plugin",
register(api: OpenClawPluginApi) {
api.registerSearchProvider({
id: "tavily",
name: "Tavily Search",
description:
"Search the web using Tavily via an external plugin provider. Returns structured results and an AI-synthesized answer when available.",
isAvailable: (config) =>
Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))),
search: async (params, ctx) => {
const pluginConfig = resolvePluginConfig(ctx.pluginConfig);
const apiKey = resolveApiKey(pluginConfig);
if (!apiKey) {
return {
error: "missing_tavily_api_key",
message:
"Tavily search provider needs an API key. Set plugins.entries.tavily-search.config.apiKey or TAVILY_API_KEY in the Gateway environment.",
};
}
const freshnessDays = resolveFreshnessDays(params.freshness);
const body: Record<string, unknown> = {
api_key: apiKey,
query: params.query,
max_results: params.count,
search_depth: resolveSearchDepth(pluginConfig),
include_answer: true,
include_raw_content: false,
topic: freshnessDays ? "news" : "general",
};
if (freshnessDays !== undefined) {
body.days = freshnessDays;
}
if (params.country) {
body.country = params.country;
}
const response = await fetch(TAVILY_SEARCH_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(Math.max(ctx.timeoutSeconds, 1) * 1000),
});
if (!response.ok) {
const detail = await response.text();
return {
error: "search_failed",
message: `Tavily search failed (${response.status}): ${detail || response.statusText}`,
};
}
const data = (await response.json()) as TavilySearchResponse;
const results = Array.isArray(data.results) ? data.results : [];
return {
content: normalizeString(data.answer),
citations: results
.map((entry) => normalizeString(entry.url))
.filter((entry): entry is string => Boolean(entry)),
results: results
.map((entry) => {
const url = normalizeString(entry.url);
if (!url) {
return undefined;
}
return {
url,
title: normalizeString(entry.title),
description: normalizeString(entry.content),
published: normalizeString(entry.published_date),
};
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
};
},
});
},
};
export default plugin;

View File

@@ -0,0 +1,16 @@
{
"id": "tavily-search",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"searchDepth": {
"type": "string",
"enum": ["basic", "advanced"]
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@openclaw/tavily-search",
"version": "2026.3.9",
"description": "OpenClaw Tavily external search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -2632,6 +2632,13 @@ function resolveSearchProviderPluginConfig(
return pluginConfig && typeof pluginConfig === "object" ? pluginConfig : undefined;
}
function formatWebSearchExecutionLog(provider: SearchProviderPlugin): string {
if (provider.pluginId) {
return `web_search: executing plugin provider "${provider.id}" from "${provider.pluginId}"`;
}
return `web_search: executing built-in provider "${provider.id}"`;
}
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
@@ -2699,6 +2706,7 @@ export function createWebSearchTool(options?: {
}
const providerId = normalizeSearchProviderId(provider.id);
logVerbose(formatWebSearchExecutionLog(provider));
const result =
!provider.pluginId && isBuiltinSearchProviderId(providerId)
? await executeBuiltinSearchProvider({