mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user