feat(sdk): expose web search

This commit is contained in:
Shoubhit Dash 2026-07-07 17:33:55 +05:30
commit 66220071b5
17 changed files with 416 additions and 0 deletions

View file

@ -887,6 +887,23 @@ export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E>
}
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.query"]>[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<ReturnType<RawClient["server.search"]["search.query"]>>
export type SearchQueryOperation<E = never> = (input: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
export interface SearchApi<E = never> {
readonly query: SearchQueryOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly location: LocationApi<E>
@ -914,4 +931,5 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly search: SearchApi<E>
}

View file

@ -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<RawClient["server.search"]["search.query"]>[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 }) =>

View file

@ -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"

View file

@ -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<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SearchApi = PromisifyApi<EffectSearchApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>

View file

@ -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<SearchQueryOutput>(
{
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,
),
},
}
}

View file

@ -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 }
}

View file

@ -9,6 +9,7 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
SearchApi,
SessionApi,
SkillApi,
} from "./api.js"

View file

@ -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({

View file

@ -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<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof SearchGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
@ -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",

View file

@ -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",

View file

@ -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.",
}),
)

View file

@ -5,11 +5,13 @@ export { ClientError } from "@opencode-ai/client/effect"
export {
AbsolutePath,
Agent,
Integration,
Location,
Model,
Prompt,
Provider,
RelativePath,
Search,
Session,
SessionInput,
SessionMessage,

View file

@ -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",
() =>

View file

@ -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<ThrowOnError extends boolean = false>(
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<never, ThrowOnError>,
) {
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<V2SearchQueryResponses, V2SearchQueryErrors, ThrowOnError>({
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 {

View file

@ -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: {

View file

@ -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,

View file

@ -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,
}),
}),
),
)
}),
)
}),
)