diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index c8d39ae5c48..5638d860714 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -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> 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 { - 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 { if (!hasKey) { provider.activation = "enabled" @@ -201,12 +207,68 @@ export const OpencodePlugin = define { + 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 remote.providers), ) }), ) } +function serverUrl(value: Credential.Value) { + return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer +} + function withoutCredentials(body: Readonly> | undefined) { return ( body && diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 57d4d7e7387..f03b1800fa9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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() + 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(() => {