mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 00:19:46 +00:00
refactor(core): share web search plugin integration setup (#61162)
This commit is contained in:
parent
7f117b189f
commit
a809a4755f
6 changed files with 195 additions and 218 deletions
|
|
@ -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<HttpClient.HttpClient | Scope.Scope>({
|
|||
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) : []
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HttpClient.HttpClient | Scope.Scope>({
|
|||
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: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HttpClient.HttpClient | Scope.Scope>({
|
|||
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 } : {}) },
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
44
packages/core/src/plugin/websearch/provider.ts
Normal file
44
packages/core/src/plugin/websearch/provider.ts
Normal file
|
|
@ -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<readonly WebSearch.Result[], unknown>
|
||||
},
|
||||
) {
|
||||
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)
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<HttpClient.HttpClient | Scope.Scope>({
|
|||
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: {},
|
||||
}))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HttpClient.HttpClient | Scope.Scope>({
|
|||
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: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue