mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 13:00:48 +00:00
Add Tavily external search plugin
This commit is contained in:
138
extensions/tavily-search/index.test.ts
Normal file
138
extensions/tavily-search/index.test.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
155
extensions/tavily-search/index.ts
Normal file
155
extensions/tavily-search/index.ts
Normal 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;
|
||||
16
extensions/tavily-search/openclaw.plugin.json
Normal file
16
extensions/tavily-search/openclaw.plugin.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "tavily-search",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"searchDepth": {
|
||||
"type": "string",
|
||||
"enum": ["basic", "advanced"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
extensions/tavily-search/package.json
Normal file
11
extensions/tavily-search/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user