feat(core): add tavily web search (#42591)

This commit is contained in:
Shoubhit Dash 2026-08-14 20:20:10 +05:30 committed by GitHub
parent 51f0b499f9
commit 3d3bbd48f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 2 deletions

View file

@ -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

View file

@ -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<HttpClient.HttpClient | Scope.Scope>({
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: {},
}))
}),
})
})
}),
})

View file

@ -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()
}),
)
})

View file

@ -467,7 +467,9 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
? "Exa"
: provider === "firecrawl"
? "Firecrawl"
: undefined
: provider === "tavily"
? "Tavily"
: undefined
if (name) return i18n.t("ui.tool.websearch.provider", { provider: name })
return i18n.t("ui.tool.websearch")
}

View file

@ -23,6 +23,7 @@ export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
if (provider === "tavily") return "Tavily Web Search"
return "Web Search"
}

View file

@ -23,6 +23,7 @@ describe("webSearchProviderLabel", () => {
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 [