From 66220071b5ce74507fa38e54d155f71d3b5b5792 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 17:33:55 +0530 Subject: [PATCH] feat(sdk): expose web search --- packages/client/src/effect/api/api.ts | 18 ++++++ .../client/src/effect/generated/client.ts | 26 ++++++++ packages/client/src/effect/index.ts | 2 + packages/client/src/promise/api.ts | 2 + .../client/src/promise/generated/client.ts | 24 +++++++ .../client/src/promise/generated/types.ts | 63 +++++++++++++++++++ packages/client/src/promise/index.ts | 1 + packages/client/test/promise.test.ts | 28 +++++++++ packages/protocol/src/api.ts | 3 + packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/search.ts | 31 +++++++++ packages/sdk-next/src/index.ts | 2 + packages/sdk-next/test/embedded.test.ts | 34 ++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 57 +++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 60 ++++++++++++++++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/search.ts | 62 ++++++++++++++++++ 17 files changed, 416 insertions(+) create mode 100644 packages/protocol/src/groups/search.ts create mode 100644 packages/server/src/handlers/search.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 90b183731b..7124d76919 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -887,6 +887,23 @@ export interface DebugApi { readonly location: DebugLocationOperation } +type Endpoint26_0Request = Parameters[0] +export type Endpoint26_0Input = { + readonly location?: Endpoint26_0Request["query"]["location"] + readonly query: Endpoint26_0Request["payload"]["query"] + readonly providerID?: Endpoint26_0Request["payload"]["providerID"] + readonly numResults?: Endpoint26_0Request["payload"]["numResults"] + readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"] + readonly type?: Endpoint26_0Request["payload"]["type"] + readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"] +} +export type Endpoint26_0Output = EffectValue> +export type SearchQueryOperation = (input: Endpoint26_0Input) => Effect.Effect + +export interface SearchApi { + readonly query: SearchQueryOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -914,4 +931,5 @@ export interface AppApi { readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi readonly debug: DebugApi + readonly search: SearchApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index ad3dac87ac..077edaa413 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1066,6 +1066,31 @@ const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint26_0Request = Parameters[0] +type Endpoint26_0Input = { + readonly location?: Endpoint26_0Request["query"]["location"] + readonly query: Endpoint26_0Request["payload"]["query"] + readonly providerID?: Endpoint26_0Request["payload"]["providerID"] + readonly numResults?: Endpoint26_0Request["payload"]["numResults"] + readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"] + readonly type?: Endpoint26_0Request["payload"]["type"] + readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"] +} +const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) => + raw["search.query"]({ + query: { location: input["location"] }, + payload: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup26 = (raw: RawClient["server.search"]) => ({ query: Endpoint26_0(raw) }) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -1093,6 +1118,7 @@ const adaptClient = (raw: RawClient) => ({ projectCopy: adaptGroup23(raw["server.projectCopy"]), vcs: adaptGroup24(raw["server.vcs"]), debug: adaptGroup25(raw["server.debug"]), + search: adaptGroup26(raw["server.search"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 8cde84695c..3cc1f1dda7 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -14,6 +14,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" @@ -36,6 +37,7 @@ export { Provider } from "@opencode-ai/schema/provider" export { Pty } from "@opencode-ai/schema/pty" export { Question } from "@opencode-ai/schema/question" export { Reference } from "@opencode-ai/schema/reference" +export { Search } from "@opencode-ai/schema/search" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index e7d1c27d25..734291c78e 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -7,6 +7,7 @@ import type { PluginApi as EffectPluginApi, ProviderApi as EffectProviderApi, ReferenceApi as EffectReferenceApi, + SearchApi as EffectSearchApi, SessionApi as EffectSessionApi, SkillApi as EffectSkillApi, } from "../effect/api/api.js" @@ -32,6 +33,7 @@ export type ModelApi = PromisifyApi> export type PluginApi = PromisifyApi> export type ProviderApi = PromisifyApi> export type ReferenceApi = PromisifyApi> +export type SearchApi = PromisifyApi> export type SessionApi = PromisifyApi> export type SkillApi = PromisifyApi> diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 692973679c..4cc2b56d4d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -177,6 +177,8 @@ import type { VcsDiffInput, VcsDiffOutput, DebugLocationOutput, + SearchQueryInput, + SearchQueryOutput, } from "./types" import { ClientError } from "./client-error" @@ -1483,6 +1485,28 @@ export function make(options: ClientOptions) { requestOptions, ), }, + search: { + query: (input: SearchQueryInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/search`, + query: { location: input["location"] }, + body: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + successStatus: 200, + declaredStatuses: [400, 503, 401], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 598abcd9ef..26cd53ec4c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -6084,3 +6084,66 @@ export type VcsDiffOutput = { } export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type SearchQueryInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly query: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["query"] + readonly providerID?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["providerID"] + readonly numResults?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["numResults"] + readonly livecrawl?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["livecrawl"] + readonly type?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["type"] + readonly contextMaxCharacters?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["contextMaxCharacters"] +} + +export type SearchQueryOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue } +} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index fd889c64e5..75b59c5d4b 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -9,6 +9,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a053752f9e..a5e4d5fae2 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => { "projectCopy", "vcs", "debug", + "search", ]) expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.message)).toEqual(["list"]) @@ -44,6 +45,7 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) + expect(Object.keys(client.search)).toEqual(["query"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -51,6 +53,32 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("search.query uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } }, + }) + }, + }) + + const result = await client.search.query({ + query: "opencode", + providerID: "exa", + numResults: 5, + location: { directory: "/tmp/project" }, + }) + + expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } }) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject") + expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 }) +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2f91fae332..d2937fade0 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -24,6 +24,7 @@ import { ReferenceGroup } from "./groups/reference.js" import { Authorization } from "./middleware/authorization.js" import { LocationGroup } from "./groups/location.js" import { IntegrationGroup } from "./groups/integration.js" +import { SearchGroup } from "./groups/search.js" import { McpGroup } from "./groups/mcp.js" import { CredentialGroup } from "./groups/credential.js" import { ProjectGroup } from "./groups/project.js" @@ -38,6 +39,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -168,6 +170,7 @@ const makeApiFromGroup = < .add(ProjectCopyGroup.middleware(locationMiddleware)) .add(VcsGroup.middleware(locationMiddleware)) .add(DebugGroup) + .add(SearchGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 58f9741842..30a6ed30ee 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -44,6 +44,7 @@ export const groupNames = { "server.generate": "generate", "server.provider": "provider", "server.integration": "integration", + "server.search": "search", "server.credential": "credential", "server.form": "form", "server.permission": "permission", diff --git a/packages/protocol/src/groups/search.ts b/packages/protocol/src/groups/search.ts new file mode 100644 index 0000000000..c0f9837986 --- /dev/null +++ b/packages/protocol/src/groups/search.ts @@ -0,0 +1,31 @@ +import { Location } from "@opencode-ai/schema/location" +import { Search } from "@opencode-ai/schema/search" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError } from "../errors.js" +import { LocationQuery, locationQueryOpenApi } from "./location.js" + +export const SearchGroup = HttpApiGroup.make("server.search") + .add( + HttpApiEndpoint.post("search.query", "/api/search", { + query: LocationQuery, + payload: Schema.Struct(Search.Input.fields), + success: Location.response(Search.Result), + error: [InvalidRequestError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.search.query", + summary: "Search the web", + description: + "Run one web search through the selected integration. Specify a provider to override the configured default.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "search", + description: "Location-scoped web search routes.", + }), + ) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index fc23219fc4..e83272d816 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -5,11 +5,13 @@ export { ClientError } from "@opencode-ai/client/effect" export { AbsolutePath, Agent, + Integration, Location, Model, Prompt, Provider, RelativePath, + Search, Session, SessionInput, SessionMessage, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 4ac31b70ab..3440a4ef5b 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -141,6 +141,40 @@ it.live( 10_000, ) +it.live("embedded client exposes integration-backed search", () => + withEmbedded("opencode-embedded-search-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const providerID = fixture.sdk.Integration.ID.make("embedded-search") + yield* opencode.plugin({ + id: `embedded-search-${crypto.randomUUID()}`, + effect: (ctx) => + ctx.integration.transform((draft) => { + draft.update(providerID, (integration) => (integration.name = "Embedded search")) + draft.capability.search.update({ + integrationID: providerID, + capability: { type: "search", connection: "optional" }, + execute: (input) => + Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), + }) + }), + }) + + const result = yield* opencode.search.query({ + query: "opencode", + providerID, + location: location(fixture), + }) + + expect(result.data).toEqual({ + providerID, + text: "Found opencode", + metadata: { source: "embedded" }, + }) + }), + ), +) + it.live( "Location-owned runner events reach the ready global client", () => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 20da64a59c..7221f1ee7e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -360,6 +360,8 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2SearchQueryErrors, + V2SearchQueryResponses, V2SessionActiveErrors, V2SessionActiveResponses, V2SessionBackgroundErrors, @@ -8119,6 +8121,56 @@ export class Debug extends HeyApiClient { } } +export class Search extends HeyApiClient { + /** + * Search the web + * + * Run one web search through the selected integration. Specify a provider to override the configured default. + */ + public query( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + query?: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "query" }, + { in: "body", key: "providerID" }, + { in: "body", key: "numResults" }, + { in: "body", key: "livecrawl" }, + { in: "body", key: "type" }, + { in: "body", key: "contextMaxCharacters" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/search", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8244,6 +8296,11 @@ export class V2 extends HeyApiClient { get debug(): Debug { return (this._debug ??= new Debug({ client: this.client })) } + + private _search?: Search + get search(): Search { + return (this._search ??= new Search({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7648445ea7..dbe737dcb5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6693,6 +6693,12 @@ export type ProjectCopyCopy = { export type VcsMode = "working" | "branch" +export type SearchResult = { + providerID: string + text: string + metadata?: unknown +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -11460,6 +11466,12 @@ export type VcsFileStatusV2 = { export type VcsMode2 = "working" | "branch" +export type SearchResult2 = { + providerID: string + text: string + metadata?: unknown +} + export type AuthRemoveData = { body?: never path: { @@ -19273,6 +19285,54 @@ export type V2DebugLocationResponses = { export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2SearchQueryData = { + body: { + query: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/search" +} + +export type V2SearchQueryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableErrorV2 +} + +export type V2SearchQueryError = V2SearchQueryErrors[keyof V2SearchQueryErrors] + +export type V2SearchQueryResponses = { + /** + * Success + */ + 200: { + location: LocationInfo2 + data: SearchResult2 + } +} + +export type V2SearchQueryResponse = V2SearchQueryResponses[keyof V2SearchQueryResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 896cbbc592..f6612faf0a 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -20,6 +20,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" +import { SearchHandler } from "./handlers/search" import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" @@ -38,6 +39,7 @@ export const handlers = Layer.mergeAll( GenerateHandler, ProviderHandler, IntegrationHandler, + SearchHandler, McpHandler, CredentialHandler, ProjectHandler, diff --git a/packages/server/src/handlers/search.ts b/packages/server/src/handlers/search.ts new file mode 100644 index 0000000000..45e5e3fbe2 --- /dev/null +++ b/packages/server/src/handlers/search.ts @@ -0,0 +1,62 @@ +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { Search } from "@opencode-ai/core/search" +import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const SearchHandler = HttpApiBuilder.group(Api, "server.search", (handlers) => + Effect.gen(function* () { + return handlers.handle( + "search.query", + Effect.fn("server.search.query")(function* (request) { + const plugins = yield* PluginSupervisor.Service + yield* plugins.ready.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Search integration initialization timed out", + service: "search", + }), + ), + }), + ) + const search = yield* Search.Service + return yield* response( + search.query(request.payload).pipe( + Effect.catchTags({ + "Search.ProviderRequired": () => + new InvalidRequestError({ + message: "Search provider is required", + kind: "search_provider_required", + field: "providerID", + }), + "Search.ProviderNotFound": (error) => + new InvalidRequestError({ + message: `Search provider not found: ${error.providerID}`, + kind: "search_provider_not_found", + field: "providerID", + }), + "Search.ConnectionRequired": (error) => + new InvalidRequestError({ + message: `Search provider requires a connection: ${error.providerID}`, + kind: "search_connection_required", + field: "providerID", + }), + "Search.Cancelled": () => + new InvalidRequestError({ message: "Search cancelled", kind: "search_cancelled" }), + "Search.Request": (error) => + new ServiceUnavailableError({ + message: `Search request failed: ${error.providerID}`, + service: error.providerID, + }), + }), + ), + ) + }), + ) + }), +)