mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-12 01:04:26 +03:00
feat(core): add console web search (#47293)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user