fix(core): share web search limits with OpenCode Web Search

OpenCode Web Search, the default search provider for Console-connected
users, made its own request with no size cap and discarded the body of
failed responses. It now uses the shared response handling, and OpenCode
API error bodies ({ _tag, message }) supply the failure explanation.

Reading a failed response for its explanation is bounded to one second
so a stalled error body still fails promptly.

Also formats the earlier changes with Prettier and tidies the tests.
This commit is contained in:
Aiden Cline
2026-09-23 12:57:33 -05:00
parent a7f3ba9a2f
commit 1c339219c1
7 changed files with 125 additions and 77 deletions
+21 -15
View File
@@ -1,4 +1,4 @@
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Equal, Option, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
@@ -11,6 +11,7 @@ import { IntegrationConnection } from "../../integration/connection.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { WebSearchResponse } from "../websearch/response.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
@@ -45,6 +46,9 @@ const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
const decodeWebSearchResponse = Schema.decodeUnknownOption(
Schema.Struct({ providerID: WebSearch.ID, results: Schema.Array(Schema.Unknown) }),
)
function oauth(http: HttpClient.HttpClient) {
return {
@@ -313,25 +317,27 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
providerID: descriptor.providerID,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
if (response.providerID !== descriptor.providerID) {
const body = yield* WebSearchResponse.execute(http, request).pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
const response = WebSearchResponse.json(body.text, body.truncated).pipe(
Option.flatMap(decodeWebSearchResponse),
)
if (Option.isNone(response))
return yield* Effect.fail(new Error("OpenCode web search returned an invalid response"))
if (response.value.providerID !== descriptor.providerID) {
return yield* Effect.fail(
new Error(
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
`OpenCode web search returned provider ${response.value.providerID} instead of ${descriptor.providerID}`,
),
)
}
return response.results
return WebSearchResponse.items(WebSearch.Result, response.value.results, body.truncated)
}),
})
editor.default.set(descriptor.providerID)
@@ -71,7 +71,9 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
const search = Option.getOrUndefined(
decodeSearchResponse(
response.result?.structuredContent ??
(content ? Option.getOrUndefined(WebSearchResponse.json(content.text, response.truncated)) : undefined),
(content
? Option.getOrUndefined(WebSearchResponse.json(content.text, response.truncated))
: undefined),
),
)
return WebSearchResponse.items(SearchResult, search?.results ?? [], response.truncated).map((item) => {
+22 -16
View File
@@ -1,9 +1,8 @@
export * as WebSearchResponse from "./response.js"
import { parseJSON } from "@opencode/ai/protocols/utils/partial-json"
import { Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientError } from "effect/unstable/http"
import type { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Duration, Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
export const MAX_BYTES = 1024 * 1024
@@ -12,22 +11,27 @@ export const execute = (http: HttpClient.HttpClient, request: HttpClientRequest.
Effect.gen(function* () {
const response = yield* HttpClient.withScope(http).execute(request)
if (response.status >= 200 && response.status < 300) return yield* read(response)
const description = yield* read(response).pipe(
Effect.map((body) =>
payloads(body.text)
.flatMap((payload) => Option.toArray(json(payload, body.truncated)))
.map(failure)
.find((message) => message !== undefined),
),
Effect.orElseSucceed(() => undefined),
)
return yield* new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({ request, response, description }),
reason: new HttpClientError.StatusCodeError({
request,
response,
description: yield* read(response).pipe(
Effect.map((body) =>
payloads(body.text)
.flatMap((payload) => Option.toArray(json(payload, body.truncated)))
.map(failure)
.find((message) => message !== undefined),
),
// The explanation is optional; a slow or broken body must not delay the failure.
Effect.timeoutOrElse({ duration: Duration.seconds(1), orElse: () => Effect.undefined }),
Effect.orElseSucceed(() => undefined),
),
}),
})
})
// Stops reading at MAX_BYTES instead of failing; parsers keep the results that arrived complete.
export const read = (response: HttpClientResponse.HttpClientResponse) =>
const read = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
let size = 0
const chunks = yield* response.stream.pipe(
@@ -58,16 +62,18 @@ const decodeFailure = Schema.decodeUnknownOption(
}),
}),
Schema.Struct({ detail: Schema.Struct({ error: Schema.String }) }),
Schema.Struct({ _tag: Schema.String, message: Schema.String }),
]),
)
// JSON-RPC errors, MCP tool errors, and Tavily's error detail.
// JSON-RPC errors, MCP tool errors, Tavily's error detail, and OpenCode API errors.
export const failure = (value: unknown) =>
Option.getOrUndefined(
Option.map(decodeFailure(value), (decoded) => {
if ("error" in decoded) return decoded.error.message
if ("result" in decoded) return decoded.result.content.map((item) => item.text).join("\n")
return decoded.detail.error
if ("detail" in decoded) return decoded.detail.error
return decoded.message
}),
)
+1 -1
View File
@@ -162,7 +162,7 @@ export const Plugin = {
const message = `Unable to search the web for ${input.query}`
// A cause becomes the model-visible error, so web search failures are described here instead;
// other causes, such as permission errors, keep their own session error.
if (Schema.is(WebSearch.RequestError)(error))
if (error instanceof WebSearch.RequestError)
return new ToolFailure({
message: `${message} (${error.providerID}): ${error.message}`,
metadata: { provider: error.providerID },
+3 -1
View File
@@ -75,7 +75,9 @@ export class RequestError extends Schema.TaggedError<RequestError>()("WebSearch.
if (status === 429) return "Rate limited (HTTP 429)"
if (status === 401) return "Authentication failed (HTTP 401)"
if (status !== undefined) return `Request failed (HTTP ${status})`
return cause.cause instanceof Error && cause.cause.message ? `Request failed: ${cause.cause.message}` : "Request failed"
return cause.cause instanceof Error && cause.cause.message
? `Request failed: ${cause.cause.message}`
: "Request failed"
}
}
@@ -17,6 +17,7 @@ import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { WebSearchResponse } from "@opencode/core/plugin/websearch/response"
import { Provider } from "@opencode/core/provider"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
@@ -805,7 +806,13 @@ describe("OpencodePlugin", () => {
body?: unknown
}> = []
const gate = Promise.withResolvers<void>()
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
const state = {
advertised: true,
providerID: "opencode",
waitForConfig: false,
unavailable: false,
oversized: false,
}
const server = Bun.serve({
port: 0,
fetch: async (request) => {
@@ -832,6 +839,11 @@ describe("OpencodePlugin", () => {
})
}
if (path === "/api/websearch" || path === "/other/api/websearch") {
if (state.unavailable)
return Response.json(
{ _tag: "ServiceUnavailableError", message: "Web search request failed: firecrawl" },
{ status: 503 },
)
return Response.json({
providerID: state.providerID,
results: [
@@ -841,6 +853,16 @@ describe("OpencodePlugin", () => {
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
...(state.oversized
? [
{
url: "https://huge.example.com",
content: "x".repeat(2 * WebSearchResponse.MAX_BYTES),
time: {},
},
{ url: "https://after.example.com", content: "after", time: {} },
]
: []),
],
})
}
@@ -927,6 +949,18 @@ describe("OpencodePlugin", () => {
value: account("replacement"),
})
state.oversized = true
expect((yield* websearch.query({ query: "oversized" })).results.map((result) => result.url)).toEqual([
"https://github.com/anomalyco/opencode",
])
state.oversized = false
state.unavailable = true
expect((yield* websearch.query({ query: "unavailable" }).pipe(Effect.flip)).message).toBe(
"HTTP 503: Web search request failed: firecrawl",
)
state.unavailable = false
state.providerID = "unexpected"
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
+40 -42
View File
@@ -30,7 +30,8 @@ beforeEach(() => {
})
const it = webSearchIntegrationTest
const sseMessage = (message: object) => `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, ...message })}\n\n`
const sseMessage = (message: object) =>
`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, ...message })}\n\n`
describe("built-in web search providers", () => {
;[
@@ -97,8 +98,7 @@ describe("built-in web search providers", () => {
describe("responses larger than the size cap", () => {
const huge = "x".repeat(2 * WebSearchResponse.MAX_BYTES)
const sse = (result: object) => sseMessage({ result })
const text = (value: string) => ({ content: [{ type: "text", text: value }] })
const mcpText = (text: string) => sseMessage({ result: { content: [{ type: "text", text }] } })
const exaBlock = (url: string, title: string, content: string) =>
`Title: ${title}\nURL: ${url}\nPublished: N/A\nAuthor: N/A\nHighlights:\n${content}`
const parallelSearch = {
@@ -113,43 +113,46 @@ describe("built-in web search providers", () => {
;[
{
plugin: WebSearchExa.Plugin,
body: sse(
text(
[
exaBlock("https://effect.website", "Effect", "Effect documentation"),
exaBlock("https://huge.example.com", "Huge", huge),
exaBlock("https://after.example.com", "After", "after"),
].join("\n\n---\n\n"),
),
providerID: WebSearch.ID.make("exa"),
body: mcpText(
[
exaBlock("https://effect.website", "Effect", "Effect documentation"),
exaBlock("https://huge.example.com", "Huge", huge),
exaBlock("https://after.example.com", "After", "after"),
].join("\n\n---\n\n"),
),
},
{
plugin: WebSearchFirecrawl.Plugin,
body: sse(
text(
JSON.stringify({
success: true,
data: {
web: [
{ url: "https://effect.website", title: "Effect", description: "Effect documentation" },
{ url: "https://huge.example.com", title: "Huge", description: huge },
{ url: "https://after.example.com", title: "After", description: "after" },
],
},
}),
),
providerID: WebSearch.ID.make("firecrawl"),
body: mcpText(
JSON.stringify({
success: true,
data: {
web: [
{ url: "https://effect.website", title: "Effect", description: "Effect documentation" },
{ url: "https://huge.example.com", title: "Huge", description: huge },
{ url: "https://after.example.com", title: "After", description: "after" },
],
},
}),
),
},
{
plugin: WebSearchParallel.Plugin,
providerID: WebSearch.ID.make("parallel"),
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { ...text(JSON.stringify(parallelSearch)), structuredContent: parallelSearch },
result: {
content: [{ type: "text", text: JSON.stringify(parallelSearch) }],
structuredContent: parallelSearch,
},
}),
},
{
plugin: WebSearchTavily.Plugin,
providerID: WebSearch.ID.make("tavily"),
body: JSON.stringify({
results: [
{ title: "Effect", url: "https://effect.website", content: "Effect documentation" },
@@ -160,16 +163,15 @@ describe("built-in web search providers", () => {
},
{
plugin: WebSearchTinyFish.Plugin,
body: sse(
text(
JSON.stringify({
results: [
{ title: "Effect", url: "https://effect.website", snippet: "Effect documentation" },
{ title: "Huge", url: "https://huge.example.com", snippet: huge },
{ title: "After", url: "https://after.example.com", snippet: "after" },
],
}),
),
providerID: WebSearch.ID.make("tinyfish"),
body: mcpText(
JSON.stringify({
results: [
{ title: "Effect", url: "https://effect.website", snippet: "Effect documentation" },
{ title: "Huge", url: "https://huge.example.com", snippet: huge },
{ title: "After", url: "https://after.example.com", snippet: "after" },
],
}),
),
},
].forEach((provider) => {
@@ -181,11 +183,10 @@ describe("built-in web search providers", () => {
yield* provider.plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const providerID = (yield* websearch.providers())[0]!.id
expect(yield* websearch.query({ query: "effect", providerID })).toEqual(
expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual(
new WebSearch.Response({
providerID,
providerID: provider.providerID,
results: [{ url: "https://effect.website", title: "Effect", content: "Effect documentation", time: {} }],
}),
)
@@ -225,10 +226,7 @@ describe("built-in web search providers", () => {
const quota =
"Free daily Search quota used (50/50). Sign up for continued access: https://agent.tinyfish.ai/sign-up"
resetWebSearchFixture(
JSON.stringify({ jsonrpc: "2.0", error: { code: -31001, message: quota }, id: 1 }),
401,
)
resetWebSearchFixture(JSON.stringify({ jsonrpc: "2.0", error: { code: -31001, message: quota }, id: 1 }), 401)
const tinyfish = yield* websearch
.query({ query: "effect", providerID: WebSearch.ID.make("tinyfish") })
.pipe(Effect.flip)