feat(sdk): add HttpApi clients and embedded host (#33445)
This commit is contained in:
parent
c45d1db9a0
commit
cdd67cf30f
59 changed files with 4629 additions and 316 deletions
27
packages/sdk-next/README.md
Normal file
27
packages/sdk-next/README.md
Normal 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.
|
||||
25
packages/sdk-next/package.json
Normal file
25
packages/sdk-next/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
16
packages/sdk-next/src/index.ts
Normal file
16
packages/sdk-next/src/index.ts
Normal 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"
|
||||
53
packages/sdk-next/src/opencode.ts
Normal file
53
packages/sdk-next/src/opencode.ts
Normal 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())
|
||||
2
packages/sdk-next/src/tool.ts
Normal file
2
packages/sdk-next/src/tool.ts
Normal 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"
|
||||
84
packages/sdk-next/test/embedded.test.ts
Normal file
84
packages/sdk-next/test/embedded.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
53
packages/sdk-next/test/import-boundaries.test.ts
Normal file
53
packages/sdk-next/test/import-boundaries.test.ts
Normal 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))
|
||||
}
|
||||
8
packages/sdk-next/tsconfig.json
Normal file
8
packages/sdk-next/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue