feat(core): add console web search (#47293)

This commit is contained in:
Adam
2026-09-08 12:08:53 -05:00
committed by GitHub
Unverified
parent e655fed6c3
commit e791afdfa3
2 changed files with 484 additions and 20 deletions
+83 -19
View File
@@ -2,18 +2,24 @@ import { Duration, Effect, Equal, 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"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const RemoteResponse = Schema.Struct({
providers: Schema.Record(Schema.String, ConfigProvider.Info),
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
})
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -61,10 +67,9 @@ function oauth(http: HttpClient.HttpClient) {
}),
refresh: (credential) =>
Effect.gen(function* () {
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
const token = yield* post(
http,
`${server}/auth/device/token`,
`${serverUrl(credential)}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
)
@@ -85,24 +90,25 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
connected: boolean
providers: typeof RemoteResponse.Type.providers | undefined
} = { connected: false, providers: undefined }
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const providers = credential
? yield* fetchProviders(http, credential).pipe(
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
return { connected: connection !== undefined, providers }
return { config, connection }
})
yield* ctx.integration.transform((editor) => {
@@ -115,7 +121,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
snapshot = yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(snapshot.providers ?? {})) {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
@@ -185,7 +191,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const item = catalog.provider.get(Provider.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connected || item.provider.settings?.apiKey)
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -201,12 +207,68 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
yield* ctx.websearch.transform((editor) => {
const descriptor = snapshot.config?.websearch
const connection = snapshot.connection
if (!descriptor || !connection) return
editor.add({
id: descriptor.providerID,
name: "OpenCode",
execute: (input) =>
Effect.gen(function* () {
const active = yield* ctx.integration.connection.active("opencode")
if (
!active ||
(connection.type === "credential"
? active.type !== "credential" || active.id !== connection.id
: active.type !== "env" || active.name !== connection.name)
) {
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
}
const credential = yield* ctx.integration.connection.resolve(active)
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
const metadata = credential.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = credential.type === "oauth" ? credential.access : credential.key
const server = yield* normalizeServer(serverUrl(credential))
const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
HttpClientRequest.schemaBodyJson(WebSearch.Input)({
query: input.query,
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) {
return yield* Effect.fail(
new Error(
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
),
)
}
return response.results
}),
})
editor.default.set(descriptor.providerID)
})
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* ctx.catalog.reload()
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
@@ -214,7 +276,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
)
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog when the loaded snapshot actually differs.
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(10)).pipe(
Effect.andThen(
loading.withPermit(
@@ -227,14 +289,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}),
})
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -245,12 +306,15 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.providers),
)
}),
)
}
function serverUrl(value: Credential.Value) {
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
}
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
@@ -3,7 +3,8 @@ import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
@@ -13,7 +14,9 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
import { drain } from "../lib/clock"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -380,6 +383,403 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
return Response.json({
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
})
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const websearch = yield* WebSearch.Service
const rebuilds = { catalog: 0, websearch: 0 }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* catalog.transform(() => {
rebuilds.catalog++
})
yield* websearch.transform(() => {
rebuilds.websearch++
})
yield* addPlugin()
yield* drain
const initial = { ...rebuilds }
expect(state.requests).toBe(1)
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("9 minutes")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode" })
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode" })
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 })
expect(yield* websearch.providers()).toEqual([])
expect(yield* websearch.default()).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{
method: string
path: string
authorization: string | null
orgID: string | null
body?: unknown
}> = []
const gate = Promise.withResolvers<void>()
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const path = new URL(request.url).pathname
const body = request.method === "POST" ? await request.json() : undefined
requests.push({
method: request.method,
path,
authorization: request.headers.get("authorization"),
orgID: request.headers.get("x-org-id"),
...(body === undefined ? {} : { body }),
})
if (path === "/api/v2/config") {
if (state.waitForConfig) await gate.promise
return Response.json({
providers: {},
...(state.advertised
? {
websearch: {
providerID: "opencode",
},
}
: {}),
})
}
if (path === "/api/websearch" || path === "/other/api/websearch") {
return Response.json({
providerID: state.providerID,
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
})
}
return new Response("Not found", { status: 404 })
},
})
return { gate, requests, server, state }
}),
({ gate, requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const account = (access: string, serverURL = server.url.origin, orgID = "org_test") =>
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access,
refresh: "refresh",
expires: Date.now() + 600_000,
metadata: { server: serverURL, orgID },
})
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("secret"),
})
yield* addPlugin()
expect(yield* websearch.providers()).toContainEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode" })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode" })
expect(yield* websearch.query({ query: "effect code search" })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("opencode"),
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
}),
)
expect(requests).toEqual([
{
method: "GET",
path: "/api/v2/config",
authorization: "Bearer secret",
orgID: "org_test",
},
{
method: "POST",
path: "/api/websearch",
authorization: "Bearer secret",
orgID: "org_test",
body: { query: "effect code search", providerID: "opencode" },
},
])
yield* credentials.update(initial.id, {
value: account("replacement"),
})
yield* websearch.query({ query: "fresh credential" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
authorization: "Bearer replacement",
body: { query: "fresh credential", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("moved", `${server.url.origin}/other///?ignored=true#ignored`),
})
yield* websearch.query({ query: "updated server" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
path: "/other/api/websearch",
authorization: "Bearer moved",
orgID: "org_test",
body: { query: "updated server", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("replacement"),
})
state.providerID = "unexpected"
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
state.advertised = false
state.waitForConfig = true
const searchCount = requests.filter((request) => request.path === "/api/websearch").length
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("switched", server.url.origin, "org_switched"),
})
yield* eventually(
Effect.sync(() => requests),
(requests) => requests.some((request) => request.authorization === "Bearer switched"),
)
expect((yield* websearch.query({ query: "switch race" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests.filter((request) => request.path === "/api/websearch")).toHaveLength(searchCount)
gate.resolve()
yield* eventually(websearch.providers(), (providers) =>
providers.every((provider) => provider.id !== WebSearch.ID.make("opencode")),
)
expect(yield* websearch.default()).toBeUndefined()
expect(requests.at(-1)).toMatchObject({
method: "GET",
path: "/api/v2/config",
authorization: "Bearer switched",
orgID: "org_switched",
})
}),
({ gate, server }) =>
Effect.sync(() => gate.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
),
)
it.live("derives hosted search identity and the default Console endpoint locally", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "managed-search",
name: "Remote name",
url: "https://example.invalid/search",
},
})
}
return Response.json({ providerID: "managed-search", results: [] })
},
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const http = yield* HttpClient.HttpClient
const requests: string[] = []
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
http.pipe(
HttpClient.mapRequest((request) => {
requests.push(request.url)
return HttpClientRequest.setUrl(request, `${server.url.origin}${new URL(request.url).pathname}`)
}),
),
),
)
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("managed-search"), name: "OpenCode" })
expect(yield* websearch.query({ query: "default Console" })).toEqual(
new WebSearch.Response({ providerID: WebSearch.ID.make("managed-search"), results: [] }),
)
expect(requests).toEqual([
"https://opencode.ai/console/api/v2/config",
"https://opencode.ai/console/api/websearch",
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not forward hosted search credentials through redirects", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const state = { crossOrigin: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
if (url.pathname === "/console/api/websearch") {
if (state.crossOrigin) url.hostname = "127.0.0.1"
return Response.redirect(`${url.origin}/outside-console`, 307)
}
return Response.json({ providerID: "opencode", results: [] })
},
})
return { requests, server, state }
}),
({ requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org_test" },
}),
})
yield* addPlugin()
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch"])
state.crossOrigin = true
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch", "/console/api/websearch"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("closes a rejected hosted search response without waiting for its body", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { cancelled: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("temporarily unavailable"))
},
cancel() {
state.cancelled = true
},
}),
{ status: 503 },
)
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_test" },
}),
})
yield* addPlugin()
const error = yield* websearch.query({ query: "rejected search" }).pipe(Effect.flip)
expect(error._tag).toBe("WebSearch.Request")
yield* eventually(
Effect.sync(() => state.cancelled),
(cancelled) => cancelled,
)
// Callers can retain errors, so response cleanup must not depend on garbage collection.
expect(error).toBeInstanceOf(WebSearch.RequestError)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {