From a809a4755fb103c8108b2cb9983b26490db5ea77 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:11:33 -0600 Subject: [PATCH] refactor(core): share web search plugin integration setup (#61162) --- packages/core/src/plugin/websearch/exa.ts | 51 ++++------- .../core/src/plugin/websearch/firecrawl.ts | 71 ++++++--------- .../core/src/plugin/websearch/parallel.ts | 79 +++++++--------- .../core/src/plugin/websearch/provider.ts | 44 +++++++++ packages/core/src/plugin/websearch/tavily.ts | 89 ++++++++----------- .../core/src/plugin/websearch/tinyfish.ts | 79 +++++++--------- 6 files changed, 195 insertions(+), 218 deletions(-) create mode 100644 packages/core/src/plugin/websearch/provider.ts diff --git a/packages/core/src/plugin/websearch/exa.ts b/packages/core/src/plugin/websearch/exa.ts index 555780f4f2af..eb383a77d5dd 100644 --- a/packages/core/src/plugin/websearch/exa.ts +++ b/packages/core/src/plugin/websearch/exa.ts @@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin" import { Effect, Schema, Scope } from "effect" import { HttpClient } from "effect/http" import { WebSearchMcp } from "./mcp.js" +import { WebSearchProvider } from "./provider.js" export const endpoint = "https://mcp.exa.ai/mcp" @@ -26,38 +27,24 @@ export const Plugin = define({ id: "opencode.websearch.exa", effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((editor) => { - editor.update("exa", (integration) => (integration.name = "Exa")) - editor.method.update({ - integrationID: "exa", - method: { type: "key" }, - }) - editor.method.update({ - integrationID: "exa", - method: { type: "env", names: ["EXA_API_KEY"] }, - }) - }) - yield* ctx.websearch.transform((editor) => { - editor.add({ - id: "exa", - name: "Exa", - execute: (input) => - Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("exa") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const url = new URL(endpoint) - if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key) - const result = yield* WebSearchMcp.call( - http, - url.toString(), - "web_search_exa", - { input: McpInput, output: McpOutput }, - { query: input.query, numResults: 8 }, - ) - const content = result?.content.find((item) => item.text) - return content ? parseResults(content.text) : [] - }), - }) + yield* WebSearchProvider.register(ctx, { + id: "exa", + name: "Exa", + env: "EXA_API_KEY", + execute: (input, credential) => + Effect.gen(function* () { + const url = new URL(endpoint) + if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key) + const result = yield* WebSearchMcp.call( + http, + url.toString(), + "web_search_exa", + { input: McpInput, output: McpOutput }, + { query: input.query, numResults: 8 }, + ) + const content = result?.content.find((item) => item.text) + return content ? parseResults(content.text) : [] + }), }) }), }) diff --git a/packages/core/src/plugin/websearch/firecrawl.ts b/packages/core/src/plugin/websearch/firecrawl.ts index 39126ffcc776..edae9e0f53d3 100644 --- a/packages/core/src/plugin/websearch/firecrawl.ts +++ b/packages/core/src/plugin/websearch/firecrawl.ts @@ -5,6 +5,7 @@ import { Effect, Option, Schema, Scope } from "effect" import { HttpClient } from "effect/http" import { App } from "../../app.js" import { WebSearchMcp } from "./mcp.js" +import { WebSearchProvider } from "./provider.js" export const endpoint = "https://mcp.firecrawl.dev/v2/mcp" @@ -37,48 +38,34 @@ export const Plugin = define({ id: "opencode.websearch.firecrawl", effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((editor) => { - editor.update("firecrawl", (integration) => (integration.name = "Firecrawl")) - editor.method.update({ - integrationID: "firecrawl", - method: { type: "key" }, - }) - editor.method.update({ - integrationID: "firecrawl", - method: { type: "env", names: ["FIRECRAWL_API_KEY"] }, - }) - }) - yield* ctx.websearch.transform((editor) => { - editor.add({ - id: "firecrawl", - name: "Firecrawl", - execute: (input) => - Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("firecrawl") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const result = yield* WebSearchMcp.call( - http, - endpoint, - "firecrawl_search", - { input: McpInput, output: McpOutput }, - { query: input.query, limit: 8 }, - { - "User-Agent": App.useragent(ctx.app), - ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), - }, - ) - const content = result?.content.find((item) => item.text) - const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined - return ( - response?.data.web.map((item) => ({ - url: item.url, - ...(item.title ? { title: item.title } : {}), - ...(item.description ? { content: item.description } : {}), - time: {}, - })) ?? [] - ) - }), - }) + yield* WebSearchProvider.register(ctx, { + id: "firecrawl", + name: "Firecrawl", + env: "FIRECRAWL_API_KEY", + execute: (input, credential) => + Effect.gen(function* () { + const result = yield* WebSearchMcp.call( + http, + endpoint, + "firecrawl_search", + { input: McpInput, output: McpOutput }, + { query: input.query, limit: 8 }, + { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), + }, + ) + const content = result?.content.find((item) => item.text) + const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined + return ( + response?.data.web.map((item) => ({ + url: item.url, + ...(item.title ? { title: item.title } : {}), + ...(item.description ? { content: item.description } : {}), + time: {}, + })) ?? [] + ) + }), }) }), }) diff --git a/packages/core/src/plugin/websearch/parallel.ts b/packages/core/src/plugin/websearch/parallel.ts index 5b858aff5260..eeef00d6f183 100644 --- a/packages/core/src/plugin/websearch/parallel.ts +++ b/packages/core/src/plugin/websearch/parallel.ts @@ -5,6 +5,7 @@ import { Effect, Schema, Scope } from "effect" import { HttpClient } from "effect/http" import { App } from "../../app.js" import { WebSearchMcp } from "./mcp.js" +import { WebSearchProvider } from "./provider.js" export const endpoint = "https://search.parallel.ai/mcp" @@ -52,52 +53,38 @@ export const Plugin = define({ id: "opencode.websearch.parallel", effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((editor) => { - editor.update("parallel", (integration) => (integration.name = "Parallel")) - editor.method.update({ - integrationID: "parallel", - method: { type: "key" }, - }) - editor.method.update({ - integrationID: "parallel", - method: { type: "env", names: ["PARALLEL_API_KEY"] }, - }) - }) - yield* ctx.websearch.transform((editor) => { - editor.add({ - id: "parallel", - name: "Parallel", - execute: (input) => - Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("parallel") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const result = yield* WebSearchMcp.call( - http, - endpoint, - "web_search", - { input: McpInput, output: McpOutput }, - { - objective: input.query, - search_queries: [input.query], - }, - { - "User-Agent": App.useragent(ctx.app), - ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), - }, - ) - return ( - result?.structuredContent.results.map((item) => { - const published = item.publish_date ? Date.parse(item.publish_date) : undefined - return { - url: item.url, - ...(item.title ? { title: item.title } : {}), - ...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}), - time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) }, - } - }) ?? [] - ) - }), - }) + yield* WebSearchProvider.register(ctx, { + id: "parallel", + name: "Parallel", + env: "PARALLEL_API_KEY", + execute: (input, credential) => + Effect.gen(function* () { + const result = yield* WebSearchMcp.call( + http, + endpoint, + "web_search", + { input: McpInput, output: McpOutput }, + { + objective: input.query, + search_queries: [input.query], + }, + { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), + }, + ) + return ( + result?.structuredContent.results.map((item) => { + const published = item.publish_date ? Date.parse(item.publish_date) : undefined + return { + url: item.url, + ...(item.title ? { title: item.title } : {}), + ...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}), + time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) }, + } + }) ?? [] + ) + }), }) }), }) diff --git a/packages/core/src/plugin/websearch/provider.ts b/packages/core/src/plugin/websearch/provider.ts new file mode 100644 index 000000000000..db33ab7ee2e8 --- /dev/null +++ b/packages/core/src/plugin/websearch/provider.ts @@ -0,0 +1,44 @@ +export * as WebSearchProvider from "./provider.js" + +import type { Context } from "@opencode/plugin/effect/plugin" +import type { Credential } from "@opencode/schema/credential" +import type { WebSearch } from "@opencode/schema/websearch" +import { Effect } from "effect" + +/** Registers a web search provider backed by an API key integration with the same ID and name. */ +export const register = Effect.fn("WebSearchProvider.register")(function* ( + ctx: Context, + provider: { + readonly id: string + readonly name: string + readonly env: string + readonly execute: ( + input: WebSearch.ProviderInput, + credential: Credential.Value | undefined, + ) => Effect.Effect + }, +) { + yield* ctx.integration.transform((editor) => { + editor.update(provider.id, (integration) => (integration.name = provider.name)) + editor.method.update({ + integrationID: provider.id, + method: { type: "key" }, + }) + editor.method.update({ + integrationID: provider.id, + method: { type: "env", names: [provider.env] }, + }) + }) + yield* ctx.websearch.transform((editor) => { + editor.add({ + id: provider.id, + name: provider.name, + execute: (input) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active(provider.id) + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + return yield* provider.execute(input, credential) + }), + }) + }) +}) diff --git a/packages/core/src/plugin/websearch/tavily.ts b/packages/core/src/plugin/websearch/tavily.ts index 54c047740133..167dfb5ca9ef 100644 --- a/packages/core/src/plugin/websearch/tavily.ts +++ b/packages/core/src/plugin/websearch/tavily.ts @@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin" import { Duration, Effect, Schema, Scope } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" import { App } from "../../app.js" +import { WebSearchProvider } from "./provider.js" export const endpoint = "https://api.tavily.com/search" @@ -28,59 +29,45 @@ export const Plugin = define({ id: "opencode.websearch.tavily", effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((editor) => { - editor.update("tavily", (integration) => (integration.name = "Tavily")) - editor.method.update({ - integrationID: "tavily", - method: { type: "key" }, - }) - editor.method.update({ - integrationID: "tavily", - method: { type: "env", names: ["TAVILY_API_KEY"] }, - }) - }) - yield* ctx.websearch.transform((editor) => { - editor.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, + yield* WebSearchProvider.register(ctx, { + id: "tavily", + name: "Tavily", + env: "TAVILY_API_KEY", + execute: (input, credential) => + Effect.gen(function* () { + 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* HttpClient.withScope(HttpClient.filterStatusOk(http)) + .execute(request) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)), + Effect.scoped, + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error("Tavily web search request timed out")), }), ) - const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)) - .execute(request) - .pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)), - Effect.scoped, - 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: {}, - })) - }), - }) + return response.results.map((item) => ({ + url: item.url, + title: item.title, + ...(item.content ? { content: item.content } : {}), + time: {}, + })) + }), }) }), }) diff --git a/packages/core/src/plugin/websearch/tinyfish.ts b/packages/core/src/plugin/websearch/tinyfish.ts index abcff56906fb..68391134a50a 100644 --- a/packages/core/src/plugin/websearch/tinyfish.ts +++ b/packages/core/src/plugin/websearch/tinyfish.ts @@ -6,6 +6,7 @@ import { HttpClient, HttpClientRequest } from "effect/http" import { App } from "../../app.js" import { collectBoundedResponseBody } from "../../tool/http-body.js" import { WebSearchMcp } from "./mcp.js" +import { WebSearchProvider } from "./provider.js" export const endpoint = "https://agent.tinyfish.ai/mcp" @@ -50,53 +51,37 @@ export const Plugin = define({ id: "opencode.websearch.tinyfish", effect: Effect.fn("WebSearchTinyFish.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((editor) => { - editor.update("tinyfish", (integration) => (integration.name = "TinyFish")) - editor.method.update({ - integrationID: "tinyfish", - method: { type: "key" }, - }) - editor.method.update({ - integrationID: "tinyfish", - method: { type: "env", names: ["TINYFISH_API_KEY"] }, - }) - }) - yield* ctx.websearch.transform((editor) => { - editor.add({ - id: "tinyfish", - name: "TinyFish", - execute: (input) => - Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("tinyfish") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const headers = { - "User-Agent": App.useragent(ctx.app), - ...(credential?.type === "key" - ? { "X-API-Key": credential.key } - : { "X-TinyFish-Access-Mode": "keyless" }), - } - // Keyed requests are rejected without the `Mcp-Session-Id` returned by `initialize`; keyless ones are not. - const session = credential?.type === "key" ? yield* initialize(http, headers) : undefined - const result = yield* WebSearchMcp.call( - http, - endpoint, - "search", - { input: McpInput, output: McpOutput }, - { query: input.query }, - { ...headers, ...(session === undefined ? {} : { "Mcp-Session-Id": session }) }, - ) - const content = result?.content.find((item) => item.text) - const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined - return ( - response?.results.map((item) => ({ - url: item.url, - title: item.title, - ...(item.snippet ? { content: item.snippet } : {}), - time: {}, - })) ?? [] - ) - }), - }) + yield* WebSearchProvider.register(ctx, { + id: "tinyfish", + name: "TinyFish", + env: "TINYFISH_API_KEY", + execute: (input, credential) => + Effect.gen(function* () { + const headers = { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { "X-API-Key": credential.key } : { "X-TinyFish-Access-Mode": "keyless" }), + } + // Keyed requests are rejected without the `Mcp-Session-Id` returned by `initialize`; keyless ones are not. + const session = credential?.type === "key" ? yield* initialize(http, headers) : undefined + const result = yield* WebSearchMcp.call( + http, + endpoint, + "search", + { input: McpInput, output: McpOutput }, + { query: input.query }, + { ...headers, ...(session === undefined ? {} : { "Mcp-Session-Id": session }) }, + ) + const content = result?.content.find((item) => item.text) + const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined + return ( + response?.results.map((item) => ({ + url: item.url, + title: item.title, + ...(item.snippet ? { content: item.snippet } : {}), + time: {}, + })) ?? [] + ) + }), }) }), })