From 3d3bbd48f94c004e7c686184c69dec1c7e0ebd1a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 14 Aug 2026 20:20:10 +0530 Subject: [PATCH] feat(core): add tavily web search (#42591) --- packages/core/src/plugin/websearch/index.ts | 8 +- packages/core/src/plugin/websearch/tavily.ts | 85 +++++++++++++++++++ packages/core/test/plugin/websearch.test.ts | 68 +++++++++++++++ .../src/components/message-part.tsx | 4 +- packages/tui/src/util/tool-display.ts | 1 + packages/tui/test/util/tool-display.test.ts | 1 + 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/plugin/websearch/tavily.ts diff --git a/packages/core/src/plugin/websearch/index.ts b/packages/core/src/plugin/websearch/index.ts index c85694221d3..58403e080aa 100644 --- a/packages/core/src/plugin/websearch/index.ts +++ b/packages/core/src/plugin/websearch/index.ts @@ -1,5 +1,11 @@ import { WebSearchExa } from "./exa.js" import { WebSearchFirecrawl } from "./firecrawl.js" import { WebSearchParallel } from "./parallel.js" +import { WebSearchTavily } from "./tavily.js" -export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const +export const WebSearchPlugins = [ + WebSearchExa.Plugin, + WebSearchFirecrawl.Plugin, + WebSearchParallel.Plugin, + WebSearchTavily.Plugin, +] as const diff --git a/packages/core/src/plugin/websearch/tavily.ts b/packages/core/src/plugin/websearch/tavily.ts new file mode 100644 index 00000000000..ca239bebc1a --- /dev/null +++ b/packages/core/src/plugin/websearch/tavily.ts @@ -0,0 +1,85 @@ +export * as WebSearchTavily from "./tavily.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Duration, Effect, Schema, Scope } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { App } from "../../app.js" + +export const endpoint = "https://api.tavily.com/search" + +const SearchRequest = Schema.Struct({ + query: Schema.String, + search_depth: Schema.Literal("basic"), + chunks_per_source: Schema.Number, + max_results: Schema.Number, +}) + +const SearchResponse = Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + title: Schema.String, + url: Schema.String, + content: Schema.String, + }), + ), +}) + +export const Plugin = define({ + id: "opencode.websearch.tavily", + effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((draft) => { + draft.update("tavily", (integration) => (integration.name = "Tavily")) + draft.method.update({ + integrationID: "tavily", + method: { type: "key" }, + }) + draft.method.update({ + integrationID: "tavily", + method: { type: "env", names: ["TAVILY_API_KEY"] }, + }) + }) + yield* ctx.websearch.transform((draft) => { + draft.add({ + id: "tavily", + name: "Tavily", + execute: (input) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active("tavily") + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const request = yield* HttpClientRequest.post(endpoint).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeaders({ + "User-Agent": App.useragent(ctx.app), + "X-Client-Name": "opencode2", + ...(credential?.type === "key" + ? { Authorization: `Bearer ${credential.key}` } + : { "X-Tavily-Access-Mode": "keyless" }), + }), + HttpClientRequest.schemaBodyJson(SearchRequest)({ + query: input.query, + search_depth: "basic", + chunks_per_source: 3, + max_results: 8, + }), + ) + const response = yield* Effect.gen(function* () { + const httpResponse = yield* HttpClient.filterStatusOk(http).execute(request) + return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(httpResponse) + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error("Tavily web search request timed out")), + }), + ) + return response.results.map((item) => ({ + url: item.url, + title: item.title, + ...(item.content ? { content: item.content } : {}), + time: {}, + })) + }), + }) + }) + }), +}) diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index 06cd5e57a93..bb55c7146d2 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -5,6 +5,7 @@ import { WebSearch } from "@opencode-ai/core/websearch" import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa" import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl" import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel" +import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily" import { host, integrationHost, webSearchHost } from "./host" import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture" @@ -190,4 +191,71 @@ describe("built-in web search providers", () => { expect(JSON.stringify(output)).not.toContain("parallel-secret") }), ) + + it.effect("registers Tavily with keyless and keyed Search API access", () => + Effect.gen(function* () { + resetWebSearchFixture( + JSON.stringify({ + query: "effect typescript", + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "Effect documentation", + score: 0.99, + }, + ], + }), + ) + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + yield* WebSearchTavily.Plugin.effect( + host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), + ) + + expect(yield* integrations.get(Integration.ID.make("tavily"))).toMatchObject({ + id: "tavily", + name: "Tavily", + methods: [{ type: "key" }, { type: "env", names: ["TAVILY_API_KEY"] }], + }) + const query = { + query: "effect typescript", + providerID: WebSearch.ID.make("tavily"), + } + expect(yield* websearch.query(query)).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("tavily"), + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "Effect documentation", + time: {}, + }, + ], + }), + ) + expect(requests[0]).toMatchObject({ + url: WebSearchTavily.endpoint, + headers: { "x-client-name": "opencode2", "x-tavily-access-mode": "keyless" }, + body: { + query: "effect typescript", + search_depth: "basic", + chunks_per_source: 3, + max_results: 8, + }, + }) + expect(requests[0]?.headers.authorization).toBeUndefined() + + yield* integrations.connection.key({ + integrationID: Integration.ID.make("tavily"), + key: "tavily-secret", + }) + yield* websearch.query(query) + expect(requests[1]).toMatchObject({ + headers: { authorization: "Bearer tavily-secret", "x-client-name": "opencode2" }, + }) + expect(requests[1]?.headers["x-tavily-access-mode"]).toBeUndefined() + }), + ) }) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 49bb4e1b45f..9dd0c6edc0d 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -467,7 +467,9 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType { test("labels known providers", () => { expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel("tavily")).toBe("Tavily Web Search") }) for (const [name, provider] of [