feat(llm): move core to package

This commit is contained in:
Kit Langton
2026-04-25 16:52:55 -04:00
parent edd176c490
commit 79683710c0
17 changed files with 458 additions and 205 deletions
+14
View File
@@ -352,6 +352,18 @@
"typescript": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.25",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.31",
@@ -1576,6 +1588,8 @@
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.25",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"effect": "catalog:"
}
}
@@ -2,7 +2,7 @@ import { Effect, Stream } from "effect"
import type { AnyPatch, Patch, PatchInput, PatchRegistry } from "./patch"
import { context, emptyRegistry, plan, registry as makePatchRegistry, target as targetPatch } from "./patch"
import type { TargetBuilder } from "./target"
import type { Transport } from "./transport"
import { Transport } from "./transport"
import type {
LLMError,
LLMEvent,
@@ -63,13 +63,12 @@ export interface AdapterDefinition<Draft, Target, Chunk> extends Adapter<Draft,
export interface LLMClient {
readonly prepare: (request: LLMRequest) => Effect.Effect<PreparedRequest, LLMError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
readonly generate: (request: LLMRequest) => Effect.Effect<LLMResponse, LLMError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError, Transport.Service>
readonly generate: (request: LLMRequest) => Effect.Effect<LLMResponse, LLMError, Transport.Service>
}
export interface ClientOptions<Draft, Target, Chunk> {
readonly adapter: Adapter<Draft, Target, Chunk>
readonly transport: Transport
readonly patches?: PatchRegistry | ReadonlyArray<AnyPatch>
readonly small?: boolean
readonly flags?: Record<string, string | number | boolean | undefined>
@@ -104,10 +103,10 @@ export function define<Draft, Target, Chunk>(input: AdapterInput<Draft, Target,
return build(input.patches ?? [])
}
export function makeClient<Draft, Target, Chunk>(options: ClientOptions<Draft, Target, Chunk>): LLMClient {
export function client<Draft, Target, Chunk>(options: ClientOptions<Draft, Target, Chunk>): LLMClient {
const registry = normalizeRegistry(options.patches)
const compile = Effect.fn("LLMCore.compile")(function* (request: LLMRequest) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
yield* assertProtocol(request.model, options.adapter)
const requestPlan = plan({
@@ -157,7 +156,7 @@ export function makeClient<Draft, Target, Chunk>(options: ClientOptions<Draft, T
return { request: patchedRequest, target, transport, patchTrace }
})
const prepare = Effect.fn("LLMCore.prepare")(function* (request: LLMRequest) {
const prepare = Effect.fn("LLM.prepare")(function* (request: LLMRequest) {
const compiled = yield* compile(request)
return new PreparedRequestSchema({
@@ -175,7 +174,8 @@ export function makeClient<Draft, Target, Chunk>(options: ClientOptions<Draft, T
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const response = yield* options.transport.fetch(compiled.transport)
const transport = yield* Transport.Service
const response = yield* transport.fetch(compiled.transport)
const streamPlan = plan({
phase: "stream",
context: context({ request: compiled.request, small: options.small, flags: options.flags }),
@@ -194,7 +194,7 @@ export function makeClient<Draft, Target, Chunk>(options: ClientOptions<Draft, T
}),
)
const generate = Effect.fn("LLMCore.generate")(function* (request: LLMRequest) {
const generate = Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const events = Array.from(yield* stream(request).pipe(Stream.runCollect))
const usage = events.reduce<LLMResponse["usage"]>(
(last, event) => ("usage" in event && event.usage !== undefined ? event.usage : last),
@@ -206,10 +206,4 @@ export function makeClient<Draft, Target, Chunk>(options: ClientOptions<Draft, T
return { prepare, stream, generate }
}
export const client = makeClient
export const Adapter = {
define,
}
export * as LLMCore from "./adapter"
export * as Adapter from "./adapter"
+7
View File
@@ -0,0 +1,7 @@
export * from "./adapter"
export * from "./patch"
export * from "./schema"
export * from "./target"
export * from "./transport"
export * as Schema from "./schema"
@@ -121,17 +121,6 @@ export function registry(patches: ReadonlyArray<AnyPatch>): PatchRegistry {
}
}
export const Patch = {
make,
request,
prompt,
toolSchema,
target,
transport,
stream,
registry,
}
export function context(input: {
readonly request: LLMRequest
readonly small?: boolean
@@ -184,4 +173,4 @@ export function mergeRegistries(registries: ReadonlyArray<PatchRegistry>): Patch
)
}
export * as LLMCorePatch from "./patch"
export * as Patch from "./patch"
@@ -420,5 +420,3 @@ export type LLMError =
| ProviderRequestError
| ProviderChunkError
| TransportError
export * as LLMCoreSchema from "./schema"
@@ -7,4 +7,4 @@ export interface TargetBuilder<Draft, Target> {
readonly validate: (draft: Draft) => Effect.Effect<Target, LLMError>
}
export * as LLMCoreTarget from "./target"
export * as Target from "./target"
+49
View File
@@ -0,0 +1,49 @@
import { Cause, Context, Effect, Layer, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { TransportError, type LLMError, type TransportRequest } from "./schema"
export interface Interface {
readonly fetch: (request: TransportRequest) => Effect.Effect<Response, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/Transport") {}
const toRequest = (request: TransportRequest) =>
HttpClientRequest.post(request.url).pipe(
HttpClientRequest.setHeaders(request.headers),
HttpClientRequest.bodyText(request.body, request.headers["content-type"]),
)
const toTransportError = (error: unknown) => {
if (Cause.isTimeoutError(error)) return new TransportError({ message: error.message })
if (!HttpClientError.isHttpClientError(error)) return new TransportError({ message: "HTTP transport failed" })
if (error.reason._tag === "TransportError") {
return new TransportError({ message: error.reason.description ?? "HTTP transport failed" })
}
return new TransportError({ message: `HTTP transport failed: ${error.reason._tag}` })
}
const withTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>, request: TransportRequest) =>
request.timeoutMs === undefined ? effect : effect.pipe(Effect.timeout(request.timeoutMs))
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return Service.of({
fetch: (request) =>
Effect.gen(function* () {
const response = yield* withTimeout(http.execute(toRequest(request)), request)
return new Response(Stream.toReadableStream(response.stream), {
status: response.status,
headers: response.headers,
})
}).pipe(Effect.mapError(toTransportError)),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
export * as Transport from "./transport"
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Adapter, client } from "../src/adapter"
import { Patch } from "../src/patch"
import {
LLMRequest,
ModelCapabilities,
ModelLimits,
ModelRef,
TransportRequest,
} from "../src/schema"
import { Transport } from "../src/transport"
import { testEffect } from "./lib/effect"
type FakeDraft = {
readonly body: string
readonly includeUsage?: boolean
}
type FakeChunk =
| { readonly type: "text"; readonly text: string }
| { readonly type: "finish"; readonly reason: "stop" }
const capabilities = new ModelCapabilities({
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true, reasoning: false },
tools: { calls: true, streamingInput: true, providerExecuted: false },
cache: { prompt: false, messageBlocks: false, contentBlocks: false },
reasoning: { efforts: [], summaries: false, encryptedContent: false },
})
const request = new LLMRequest({
id: "req_1",
model: new ModelRef({
id: "fake-model",
provider: "fake-provider",
protocol: "openai-chat",
capabilities,
limits: new ModelLimits({}),
}),
system: [],
messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
tools: [],
generation: {},
})
const fake = Adapter.define<FakeDraft, FakeDraft, FakeChunk>({
id: "fake",
protocol: "openai-chat",
builder: {
empty: { body: "" },
concat: (left, right) => Effect.succeed({ ...left, ...right }),
validate: (draft) => Effect.succeed(draft),
},
redact: (target) => ({ ...target, redacted: true }),
prepare: (request) =>
Effect.succeed({
body: [
...request.messages
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text),
...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`),
]
.join("\n"),
}),
toTransport: (target) =>
Effect.succeed(
new TransportRequest({
url: "https://fake.local/chat",
method: "POST",
headers: {},
body: JSON.stringify(target),
}),
),
parse: (response) =>
Stream.fromEffect(Effect.promise(async () => (await response.json()) as FakeChunk[])).pipe(Stream.flatMap(Stream.fromIterable)),
raise: (chunk) => {
if (chunk.type === "finish") return Stream.make({ type: "request-finish", reason: chunk.reason })
return Stream.make({ type: "text-delta", text: chunk.text })
},
})
const transportLayer = Layer.succeed(
Transport.Service,
Transport.Service.of({
fetch: (request) =>
Effect.succeed(
new Response(JSON.stringify([{ type: "text", text: `echo:${request.body}` }, { type: "finish", reason: "stop" }])),
),
}),
)
const it = testEffect(transportLayer)
describe("llm adapter", () => {
test("prepare applies target and transport patches with trace", async () => {
const llm = client({
adapter: fake.withPatches([
fake.patch("include-usage", {
reason: "fake target patch",
apply: (draft) => ({ ...draft, includeUsage: true }),
}),
]),
patches: [
Patch.transport("fake.header", {
reason: "fake transport patch",
apply: (request) => ({ ...request, headers: { ...request.headers, "x-fake": "1" } }),
}),
],
})
const prepared = await Effect.runPromise(llm.prepare(request))
expect(prepared.redactedTarget).toEqual({ body: "hello", includeUsage: true, redacted: true })
expect(prepared.transport.headers).toEqual({ "x-fake": "1" })
expect(prepared.patchTrace.map((item) => item.id)).toEqual(["target.fake.include-usage", "transport.fake.header"])
})
it.effect("stream and generate use the adapter pipeline", () =>
Effect.gen(function* () {
const llm = client({ adapter: fake })
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
const response = yield* llm.generate(request)
expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
}),
)
test("request, prompt, and tool-schema patches run before adapter prepare", async () => {
const llm = client({
adapter: fake,
patches: [
Patch.request("test.id", {
reason: "rewrite request id",
apply: (request) => ({ ...request, id: "req_patched" }),
}),
Patch.prompt("test.message", {
reason: "rewrite prompt text",
apply: (request) => ({
...request,
messages: request.messages.map((message) => ({
...message,
content: message.content.map((part) => (part.type === "text" ? { ...part, text: "patched" } : part)),
})),
}),
}),
Patch.toolSchema("test.description", {
reason: "rewrite tool description",
apply: (tool) => ({ ...tool, description: "patched tool" }),
}),
],
})
const prepared = await Effect.runPromise(
llm.prepare(
new LLMRequest({
...request,
tools: [{ name: "lookup", description: "original", inputSchema: {} }],
}),
),
)
expect(prepared.id).toBe("req_patched")
expect(prepared.target).toEqual({ body: "patched\ntool:lookup:patched tool" })
expect(prepared.patchTrace.map((item) => item.id)).toEqual([
"request.test.id",
"prompt.test.message",
"schema.test.description",
])
})
it.effect("stream patches transform raised events", () =>
Effect.gen(function* () {
const llm = client({
adapter: fake,
patches: [
Patch.stream("test.uppercase", {
reason: "uppercase text deltas",
apply: (event) => (event.type === "text-delta" ? { ...event, text: event.text.toUpperCase() } : event),
}),
],
})
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
expect(events[0]).toEqual({ type: "text-delta", text: 'ECHO:{"BODY":"HELLO"}' })
}),
)
test("rejects protocol mismatch", async () => {
const llm = client({ adapter: fake })
await expect(
Effect.runPromise(
llm.prepare(
new LLMRequest({
...request,
model: new ModelRef({ ...request.model, protocol: "gemini" }),
}),
),
),
).rejects.toThrow("No LLM adapter")
})
})
+50
View File
@@ -0,0 +1,50 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, testLayer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, testLayer), opts)
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, liveLayer), opts)
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, liveLayer), opts)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts)
return { effect, live }
}
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const liveEnv = TestConsole.layer
export const it = make(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { LLMRequest, ModelCapabilities, ModelLimits, ModelRef } from "../../src/llm-core/schema"
import { Model, Patch, Request, context, plan } from "../../src/llm-core/patch"
import { Model, Patch, Request, context, plan } from "../src/patch"
import { LLMRequest, ModelCapabilities, ModelLimits, ModelRef } from "../src/schema"
const capabilities = new ModelCapabilities({
input: { text: true, image: false, audio: false, video: false, pdf: false },
@@ -25,7 +25,7 @@ const request = new LLMRequest({
generation: {},
})
describe("llm-core patch", () => {
describe("llm patch", () => {
test("constructors prefix ids and registry groups by phase", () => {
const prompt = Patch.prompt("mistral.test", {
reason: "test prompt",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ContentPart, LLMEvent, LLMRequest, ModelCapabilities, ModelLimits, ModelRef } from "../../src/llm-core/schema"
import { ContentPart, LLMEvent, LLMRequest, ModelCapabilities, ModelLimits, ModelRef } from "../src/schema"
const capabilities = new ModelCapabilities({
input: { text: true, image: false, audio: false, video: false, pdf: false },
@@ -18,7 +18,7 @@ const model = new ModelRef({
limits: new ModelLimits({}),
})
describe("llm-core schema", () => {
describe("llm schema", () => {
test("decodes a minimal request", () => {
const input: unknown = {
id: "req_1",
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { TransportRequest } from "../src/schema"
import { Transport } from "../src/transport"
import { testEffect } from "./lib/effect"
const encoder = new TextEncoder()
const http = HttpClient.make((request) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
expect(web.method).toBe("POST")
expect(web.headers.get("authorization")).toBe("Bearer test")
expect(yield* Effect.promise(() => web.text())).toBe("hello")
return HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("ok"))
controller.close()
},
}),
{ status: 202, headers: { "content-type": "text/plain" } },
),
)
}),
)
const it = testEffect(Transport.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))))
describe("llm transport", () => {
it.effect("executes TransportRequest through HttpClient", () =>
Effect.gen(function* () {
const transport = yield* Transport.Service
const response = yield* transport.fetch(
new TransportRequest({
url: "https://fake.local/chat",
method: "POST",
headers: { authorization: "Bearer test", "content-type": "text/plain" },
body: "hello",
}),
)
expect(response.status).toBe(202)
expect(response.headers.get("content-type")).toBe("text/plain")
expect(yield* Effect.promise(() => response.text())).toBe("ok")
}),
)
})
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+24 -26
View File
@@ -84,8 +84,8 @@ Initial in-repo import shape:
import { LLMRequest, LLMEvent, LLMClient } from "@opencode-ai/llm"
```
Until it becomes a package, this can live under `packages/opencode/src/llm-core`
with the same module boundaries.
The first implementation lives in `packages/llm` so the package boundary stays
honest from the start.
### Module responsibilities
@@ -110,17 +110,16 @@ Keep module boundaries strict so the package stays portable.
chunk-to-event raising, and default protocol patches.
- `patch/*` owns reusable named patches that are not tied to one adapter file.
If the first version lands under `packages/opencode/src/llm-core`, each module
should follow the repo's self-export pattern, for example:
Each module should follow the repo's self-export pattern, for example:
```ts
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMCore") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/Transport") {}
export * as LLMCore from "./client"
export * as Transport from "./transport"
```
The standalone package can expose a package-level `index.ts` later, but internal
multi-sibling directories should avoid broad barrels.
The package exposes a small package-level `index.ts`; internal multi-sibling
directories should still avoid broad barrels.
## Public API
@@ -165,14 +164,13 @@ export const client: (options: ClientOptions) => Effect.Effect<LLMClient, LLMErr
Consumer-side opencode code should be this small:
```ts
const llm = yield* LLMCore.client({
const llm = client({
adapters: AdapterRegistry.make([
OpenAIChat.adapter,
OpenAIResponses.adapter,
Anthropic.adapter,
Gemini.adapter,
]),
transport: Transport.fetch,
patches: OpenCodePatches.default,
})
@@ -197,7 +195,7 @@ wiring can use layers without forcing standalone consumers to do the same:
```ts
export interface Interface extends LLMClient {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMCore") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
```
`client` should be the implementation primitive. The service layer should be thin
@@ -807,20 +805,21 @@ export interface PatchRegistry {
}
```
Recommended opencode layout:
Recommended package/opencode layout:
```text
src/llm-core/
packages/llm/src/
patch.ts
patches/
prompt.ts # shared history/request compatibility patches
schema.ts # shared tool/JSON schema transforms
transport.ts # shared header/routing patches
index.ts # OpenCodePatches.default
provider/
openai-chat.ts # adapter + typed OpenAI target patches
anthropic.ts # adapter + typed Anthropic target patches
gemini.ts # adapter + typed Gemini target patches
packages/opencode/src/provider/patch/
prompt.ts # shared history/request compatibility patches
schema.ts # shared tool/JSON schema transforms
transport.ts # shared header/routing patches
index.ts # OpenCodePatches.default
```
Normal opencode code should import only the final registry:
@@ -1604,8 +1603,7 @@ confidence.
Goal: define the standalone API without touching opencode runtime behavior.
1. Add `packages/llm` or `packages/opencode/src/llm-core` with no imports from
opencode session modules.
1. Add `packages/llm` with no imports from opencode session modules.
2. Add `schema.ts` with `ModelRef`, `LLMRequest`, `Message`, `ContentPart`,
`ToolDefinition`, `LLMEvent`, `Usage`, and errors.
3. Add `target.ts` with `TargetBuilder`, `TargetFragment`, and `TargetSlot`.
@@ -1714,8 +1712,8 @@ Acceptance criteria:
Use these defaults unless implementation proves they are wrong.
- Land the first version under `packages/opencode/src/llm-core` only if creating a
workspace package slows the prototype. Keep imports package-clean either way.
- Keep the first version in `packages/llm`; do not move package-generic code back
into `packages/opencode` during integration.
- Treat patch IDs as internal until config, plugin, or public docs reference them.
Once referenced externally, require stable IDs and deprecation notes.
- Keep `ModelRef.native` and `LLMRequest.native` as
@@ -1771,11 +1769,11 @@ Mitigation:
The smallest useful implementation should be docs-to-code mechanical.
1. Create `llm-core/schema.ts` with only schemas and errors.
2. Create `llm-core/patch.ts` with pure patch planning and trace tests.
3. Create `llm-core/target.ts` with the minimal `TargetBuilder` interface. Add
1. Create `packages/llm/src/schema.ts` with only schemas and errors.
2. Create `packages/llm/src/patch.ts` with pure patch planning and trace tests.
3. Create `packages/llm/src/target.ts` with the minimal `TargetBuilder` interface. Add
fragments only when a real adapter needs them.
4. Create `llm-core/adapter.ts` with the shared runner but no real provider.
4. Create `packages/llm/src/adapter.ts` with the shared runner but no real provider.
5. Add a fake adapter and in-memory transport contract test.
6. Add `provider/openai-chat.ts` only after the fake adapter proves the runner
boundaries.
@@ -1,8 +0,0 @@
import type { Effect } from "effect"
import type { LLMError, TransportRequest } from "./schema"
export interface Transport {
readonly fetch: (request: TransportRequest) => Effect.Effect<Response, LLMError>
}
export * as LLMCoreTransport from "./transport"
@@ -1,135 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Stream } from "effect"
import { Adapter, client } from "../../src/llm-core/adapter"
import { Patch } from "../../src/llm-core/patch"
import {
LLMRequest,
ModelCapabilities,
ModelLimits,
ModelRef,
TransportRequest,
} from "../../src/llm-core/schema"
import type { Transport } from "../../src/llm-core/transport"
type FakeDraft = {
readonly body: string
readonly includeUsage?: boolean
}
type FakeChunk =
| { readonly type: "text"; readonly text: string }
| { readonly type: "finish"; readonly reason: "stop" }
const capabilities = new ModelCapabilities({
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true, reasoning: false },
tools: { calls: true, streamingInput: true, providerExecuted: false },
cache: { prompt: false, messageBlocks: false, contentBlocks: false },
reasoning: { efforts: [], summaries: false, encryptedContent: false },
})
const request = new LLMRequest({
id: "req_1",
model: new ModelRef({
id: "fake-model",
provider: "fake-provider",
protocol: "openai-chat",
capabilities,
limits: new ModelLimits({}),
}),
system: [],
messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
tools: [],
generation: {},
})
const fake = Adapter.define<FakeDraft, FakeDraft, FakeChunk>({
id: "fake",
protocol: "openai-chat",
builder: {
empty: { body: "" },
concat: (left, right) => Effect.succeed({ ...left, ...right }),
validate: (draft) => Effect.succeed(draft),
},
redact: (target) => ({ ...target, redacted: true }),
prepare: (request) =>
Effect.succeed({
body: request.messages
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n"),
}),
toTransport: (target) =>
Effect.succeed(
new TransportRequest({
url: "https://fake.local/chat",
method: "POST",
headers: {},
body: JSON.stringify(target),
}),
),
parse: (response) =>
Stream.fromEffect(Effect.promise(async () => (await response.json()) as FakeChunk[])).pipe(Stream.flatMap(Stream.fromIterable)),
raise: (chunk) => {
if (chunk.type === "finish") return Stream.make({ type: "request-finish", reason: chunk.reason })
return Stream.make({ type: "text-delta", text: chunk.text })
},
})
const transport: Transport = {
fetch: (request) =>
Effect.succeed(
new Response(JSON.stringify([{ type: "text", text: `echo:${request.body}` }, { type: "finish", reason: "stop" }])),
),
}
describe("llm-core adapter", () => {
test("prepare applies target and transport patches with trace", async () => {
const llm = client({
adapter: fake.withPatches([
fake.patch("include-usage", {
reason: "fake target patch",
apply: (draft) => ({ ...draft, includeUsage: true }),
}),
]),
transport,
patches: [
Patch.transport("fake.header", {
reason: "fake transport patch",
apply: (request) => ({ ...request, headers: { ...request.headers, "x-fake": "1" } }),
}),
],
})
const prepared = await Effect.runPromise(llm.prepare(request))
expect(prepared.redactedTarget).toEqual({ body: "hello", includeUsage: true, redacted: true })
expect(prepared.transport.headers).toEqual({ "x-fake": "1" })
expect(prepared.patchTrace.map((item) => item.id)).toEqual(["target.fake.include-usage", "transport.fake.header"])
})
test("stream and generate use the adapter pipeline", async () => {
const llm = client({ adapter: fake, transport })
const events = Array.from(await Effect.runPromise(llm.stream(request).pipe(Stream.runCollect)))
const response = await Effect.runPromise(llm.generate(request))
expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
})
test("rejects protocol mismatch", async () => {
const llm = client({ adapter: fake, transport })
await expect(
Effect.runPromise(
llm.prepare(
new LLMRequest({
...request,
model: new ModelRef({ ...request.model, protocol: "gemini" }),
}),
),
),
).rejects.toThrow("No LLM adapter")
})
})