Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/plugin/websearch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
82 changes: 82 additions & 0 deletions packages/core/src/plugin/websearch/tinyfish.ts
Original file line number Diff line number Diff line change
@@ -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: {},
})) ?? []
)
}),
})
})
}),
})
119 changes: 101 additions & 18 deletions packages/core/test/plugin/websearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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* () {
Expand Down Expand Up @@ -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()
}),
)
})
Loading