feat(core): add TinyFish web search (#48561)

Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-12 02:10:54 -05:00
committed by GitHub
Unverified
parent 9f3ba44c4a
commit 3f579f53f4
3 changed files with 185 additions and 18 deletions
@@ -2,10 +2,12 @@ import { WebSearchExa } from "./exa.js"
import { WebSearchFirecrawl } from "./firecrawl.js"
import { WebSearchParallel } from "./parallel.js"
import { WebSearchTavily } from "./tavily.js"
import { WebSearchTinyFish } from "./tinyfish.js"
export const WebSearchPlugins = [
WebSearchExa.Plugin,
WebSearchFirecrawl.Plugin,
WebSearchParallel.Plugin,
WebSearchTavily.Plugin,
WebSearchTinyFish.Plugin,
] as const
@@ -0,0 +1,82 @@
export * as WebSearchTinyFish from "./tinyfish.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
export const endpoint = "https://agent.tinyfish.ai/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.String,
snippet: Schema.String,
}),
),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
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 result = yield* WebSearchMcp.call(
http,
endpoint,
"search",
{ input: McpInput, output: McpOutput },
{ query: input.query },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key"
? { "X-API-Key": credential.key }
: { "X-TinyFish-Access-Mode": "keyless" }),
},
)
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: {},
})) ?? []
)
}),
})
})
}),
})
+101 -18
View File
@@ -6,6 +6,7 @@ import { WebSearchExa } from "@opencode/core/plugin/websearch/exa"
import { WebSearchFirecrawl } from "@opencode/core/plugin/websearch/firecrawl"
import { WebSearchParallel } from "@opencode/core/plugin/websearch/parallel"
import { WebSearchTavily } from "@opencode/core/plugin/websearch/tavily"
import { WebSearchTinyFish } from "@opencode/core/plugin/websearch/tinyfish"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
@@ -30,24 +31,26 @@ beforeEach(() => {
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach(
(plugin) => {
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
Effect.gen(function* () {
resetWebSearchFixture("Rate limited", 429)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* websearch.select("random")
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(signals).toHaveLength(1)
expect(signals[0]?.aborted).toBe(true)
}),
)
},
)
;[
WebSearchExa.Plugin,
WebSearchParallel.Plugin,
WebSearchFirecrawl.Plugin,
WebSearchTavily.Plugin,
WebSearchTinyFish.Plugin,
].forEach((plugin) => {
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
Effect.gen(function* () {
resetWebSearchFixture("Rate limited", 429)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* plugin.effect(host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }))
yield* websearch.select("random")
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(signals).toHaveLength(1)
expect(signals[0]?.aborted).toBe(true)
}),
)
})
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
@@ -277,4 +280,84 @@ describe("built-in web search providers", () => {
expect(requests[1]?.headers["x-tavily-access-mode"]).toBeUndefined()
}),
)
it.effect("registers TinyFish with keyless and keyed MCP search access", () =>
Effect.gen(function* () {
resetWebSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: JSON.stringify({
query: "effect typescript",
results: [
{
position: 1,
site_name: "effect.website",
snippet: "Effect documentation",
title: "Effect",
url: "https://effect.website",
},
],
total_results: 1,
page: 0,
}),
},
],
},
}),
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchTinyFish.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("tinyfish"))).toMatchObject({
id: "tinyfish",
name: "TinyFish",
methods: [{ type: "key" }, { type: "env", names: ["TINYFISH_API_KEY"] }],
})
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("tinyfish") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("tinyfish"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: {},
},
],
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchTinyFish.endpoint,
headers: { "x-tinyfish-access-mode": "keyless" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "search",
arguments: { query: "effect typescript" },
},
},
})
expect(requests[0]?.headers.authorization).toBeUndefined()
yield* integrations.connection.key({
integrationID: Integration.ID.make("tinyfish"),
key: "tinyfish-secret",
})
yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("tinyfish") })
expect(requests[1]).toMatchObject({
headers: { "x-api-key": "tinyfish-secret" },
})
expect(requests[1]?.headers["x-tinyfish-access-mode"]).toBeUndefined()
}),
)
})