feat(sdk): add HttpApi clients and embedded host (#33445)

This commit is contained in:
Kit Langton 2026-06-25 05:08:54 +02:00 committed by GitHub
commit cdd67cf30f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 4629 additions and 316 deletions

View file

@ -0,0 +1,27 @@
# @opencode-ai/sdk-next
Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
The same constructor is available as a service Layer:
```ts
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.get({ sessionID })
})
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.

View file

@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk-next",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View file

@ -0,0 +1,16 @@
export * as OpenCode from "./opencode"
export * as Tool from "./tool"
export { ClientError } from "@opencode-ai/client/effect"
export {
AbsolutePath,
Agent,
Location,
Model,
Prompt,
Provider,
RelativePath,
Session,
SessionInput,
SessionMessage,
} from "@opencode-ai/client/effect"

View file

@ -0,0 +1,53 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Cause, Context, Effect, Layer } from "effect"
import {
HttpClient,
HttpRouter,
HttpServer,
HttpServerError,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* () {
const applicationTools = ApplicationTools.layer
const { handler, permissions, tools } = yield* Effect.all({
// Reusing this Layer value lets registration and every Location share one memoized host-level registry.
handler: HttpRouter.toHttpEffect(
createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)),
),
permissions: PermissionSaved.Service,
tools: ApplicationTools.Service,
}).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer)))
const httpClient = HttpClient.make(
Effect.fnUntraced(function* (request) {
const response = yield* handler.pipe(
Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)),
Effect.provideService(ApplicationTools.Service, tools),
Effect.provideService(PermissionSaved.Service, permissions),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)),
),
)
return HttpServerResponse.toClientResponse(response, { request })
}, Effect.scoped),
)
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
)
return {
...client,
tools: { register: tools.register },
}
})
export type Interface = Effect.Success<ReturnType<typeof create>>
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
export const layer = Layer.effect(Service, create())

View file

@ -0,0 +1,2 @@
export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool"
export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool"

View file

@ -0,0 +1,84 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Schema } from "effect"
test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
try {
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.create()
yield* opencode.tools.register({
embedded_tool: Tool.make({
description: "Embedded test tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
const created = yield* opencode.sessions.create({
id: sessionID,
agent: Agent.ID.make("build"),
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
const selected = yield* opencode.sessions.get({ sessionID })
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
const admitted = yield* opencode.sessions.prompt({
sessionID,
prompt: Prompt.make({ text: "Do not run" }),
resume: false,
})
const context = yield* opencode.sessions.context({ sessionID })
const missing = yield* Effect.flip(
opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }),
)
expect(created.id).toBe(sessionID)
expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID)
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
expect(admitted.sessionID).toBe(sessionID)
expect(context.some((message) => message.type === "model-switched")).toBe(true)
expect(missing._tag).toBe("SessionNotFoundError")
})
await Effect.runPromise(Effect.scoped(program))
} finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true })
}
})
test("embedded client is available as a Layer service", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
try {
const created = await Effect.runPromise(
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
}).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
)
expect(created.id).toBe(sessionID)
} finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true })
}
})

View file

@ -0,0 +1,53 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const client = resolve(import.meta.dir, "../../client")
const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
test("bundles the client and in-memory host", async () => {
const inputs = await bundleInputs()
expect(within(inputs, client).length).toBeGreaterThan(0)
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
})
async function bundleInputs() {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
const child = Bun.spawn(
[
process.execPath,
"build",
entrypoint,
"--target=bun",
"--format=esm",
"--packages=bundle",
`--metafile=${metafile}`,
`--outdir=${join(temporary, "out")}`,
],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function within(inputs: ReadonlyArray<string>, directory: string) {
const prefix = directory.endsWith(sep) ? directory : directory + sep
return inputs.filter((input) => input === directory || input.startsWith(prefix))
}

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}