From 79683710c075bb6c1498150347641af3391a33db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Apr 2026 16:52:55 -0400 Subject: [PATCH] feat(llm): move core to package --- bun.lock | 14 ++ packages/llm/package.json | 24 ++ .../src/llm-core => llm/src}/adapter.ts | 26 +-- packages/llm/src/index.ts | 7 + .../src/llm-core => llm/src}/patch.ts | 13 +- .../src/llm-core => llm/src}/schema.ts | 2 - .../src/llm-core => llm/src}/target.ts | 2 +- packages/llm/src/transport.ts | 49 +++++ packages/llm/test/adapter.test.ts | 206 ++++++++++++++++++ packages/llm/test/lib/effect.ts | 50 +++++ .../test/llm-core => llm/test}/patch.test.ts | 6 +- .../test/llm-core => llm/test}/schema.test.ts | 4 +- packages/llm/test/transport.test.ts | 53 +++++ packages/llm/tsconfig.json | 14 ++ .../opencode/specs/effect/llm-adapters.md | 50 ++--- packages/opencode/src/llm-core/transport.ts | 8 - .../opencode/test/llm-core/adapter.test.ts | 135 ------------ 17 files changed, 458 insertions(+), 205 deletions(-) create mode 100644 packages/llm/package.json rename packages/{opencode/src/llm-core => llm/src}/adapter.ts (92%) create mode 100644 packages/llm/src/index.ts rename packages/{opencode/src/llm-core => llm/src}/patch.ts (97%) rename packages/{opencode/src/llm-core => llm/src}/schema.ts (99%) rename packages/{opencode/src/llm-core => llm/src}/target.ts (87%) create mode 100644 packages/llm/src/transport.ts create mode 100644 packages/llm/test/adapter.test.ts create mode 100644 packages/llm/test/lib/effect.ts rename packages/{opencode/test/llm-core => llm/test}/patch.test.ts (94%) rename packages/{opencode/test/llm-core => llm/test}/schema.test.ts (95%) create mode 100644 packages/llm/test/transport.test.ts create mode 100644 packages/llm/tsconfig.json delete mode 100644 packages/opencode/src/llm-core/transport.ts delete mode 100644 packages/opencode/test/llm-core/adapter.test.ts diff --git a/bun.lock b/bun.lock index fcd8e94431..2c3efd1c40 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000000..baeff77e21 --- /dev/null +++ b/packages/llm/package.json @@ -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:" + } +} diff --git a/packages/opencode/src/llm-core/adapter.ts b/packages/llm/src/adapter.ts similarity index 92% rename from packages/opencode/src/llm-core/adapter.ts rename to packages/llm/src/adapter.ts index e816f09d8c..2f77ffb809 100644 --- a/packages/opencode/src/llm-core/adapter.ts +++ b/packages/llm/src/adapter.ts @@ -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 extends Adapter Effect.Effect - readonly stream: (request: LLMRequest) => Stream.Stream - readonly generate: (request: LLMRequest) => Effect.Effect + readonly stream: (request: LLMRequest) => Stream.Stream + readonly generate: (request: LLMRequest) => Effect.Effect } export interface ClientOptions { readonly adapter: Adapter - readonly transport: Transport readonly patches?: PatchRegistry | ReadonlyArray readonly small?: boolean readonly flags?: Record @@ -104,10 +103,10 @@ export function define(input: AdapterInput(options: ClientOptions): LLMClient { +export function client(options: ClientOptions): 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(options: ClientOptions(options: ClientOptions(options: ClientOptions( (last, event) => ("usage" in event && event.usage !== undefined ? event.usage : last), @@ -206,10 +206,4 @@ export function makeClient(options: ClientOptions): 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): Patch ) } -export * as LLMCorePatch from "./patch" +export * as Patch from "./patch" diff --git a/packages/opencode/src/llm-core/schema.ts b/packages/llm/src/schema.ts similarity index 99% rename from packages/opencode/src/llm-core/schema.ts rename to packages/llm/src/schema.ts index abc878e0f3..7cf4eeb2ae 100644 --- a/packages/opencode/src/llm-core/schema.ts +++ b/packages/llm/src/schema.ts @@ -420,5 +420,3 @@ export type LLMError = | ProviderRequestError | ProviderChunkError | TransportError - -export * as LLMCoreSchema from "./schema" diff --git a/packages/opencode/src/llm-core/target.ts b/packages/llm/src/target.ts similarity index 87% rename from packages/opencode/src/llm-core/target.ts rename to packages/llm/src/target.ts index 3b38bc7aa1..d81f2d3487 100644 --- a/packages/opencode/src/llm-core/target.ts +++ b/packages/llm/src/target.ts @@ -7,4 +7,4 @@ export interface TargetBuilder { readonly validate: (draft: Draft) => Effect.Effect } -export * as LLMCoreTarget from "./target" +export * as Target from "./target" diff --git a/packages/llm/src/transport.ts b/packages/llm/src/transport.ts new file mode 100644 index 0000000000..72745e80df --- /dev/null +++ b/packages/llm/src/transport.ts @@ -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 +} + +export class Service extends Context.Service()("@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 = (effect: Effect.Effect, request: TransportRequest) => + request.timeoutMs === undefined ? effect : effect.pipe(Effect.timeout(request.timeoutMs)) + +export const layer: Layer.Layer = 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" diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts new file mode 100644 index 0000000000..307e58382f --- /dev/null +++ b/packages/llm/test/adapter.test.ts @@ -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({ + 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") + }) +}) diff --git a/packages/llm/test/lib/effect.ts b/packages/llm/test/lib/effect.ts new file mode 100644 index 0000000000..05cf017b2b --- /dev/null +++ b/packages/llm/test/lib/effect.ts @@ -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 = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + 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 = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, 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 = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/opencode/test/llm-core/patch.test.ts b/packages/llm/test/patch.test.ts similarity index 94% rename from packages/opencode/test/llm-core/patch.test.ts rename to packages/llm/test/patch.test.ts index 481c8d0eff..c8938588b5 100644 --- a/packages/opencode/test/llm-core/patch.test.ts +++ b/packages/llm/test/patch.test.ts @@ -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", diff --git a/packages/opencode/test/llm-core/schema.test.ts b/packages/llm/test/schema.test.ts similarity index 95% rename from packages/opencode/test/llm-core/schema.test.ts rename to packages/llm/test/schema.test.ts index e80ed556ff..30e12c4ba8 100644 --- a/packages/opencode/test/llm-core/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -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", diff --git a/packages/llm/test/transport.test.ts b/packages/llm/test/transport.test.ts new file mode 100644 index 0000000000..6bfeb2b358 --- /dev/null +++ b/packages/llm/test/transport.test.ts @@ -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") + }), + ) +}) diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 0000000000..d7745d7554 --- /dev/null +++ b/packages/llm/tsconfig.json @@ -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/*"] + } + ] + } +} diff --git a/packages/opencode/specs/effect/llm-adapters.md b/packages/opencode/specs/effect/llm-adapters.md index 6c712b1e7c..22f41ca937 100644 --- a/packages/opencode/specs/effect/llm-adapters.md +++ b/packages/opencode/specs/effect/llm-adapters.md @@ -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()("@opencode/LLMCore") {} +export class Service extends Context.Service()("@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()("@opencode/LLMCore") {} +export class Service extends Context.Service()("@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. diff --git a/packages/opencode/src/llm-core/transport.ts b/packages/opencode/src/llm-core/transport.ts deleted file mode 100644 index 59954d72c7..0000000000 --- a/packages/opencode/src/llm-core/transport.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Effect } from "effect" -import type { LLMError, TransportRequest } from "./schema" - -export interface Transport { - readonly fetch: (request: TransportRequest) => Effect.Effect -} - -export * as LLMCoreTransport from "./transport" diff --git a/packages/opencode/test/llm-core/adapter.test.ts b/packages/opencode/test/llm-core/adapter.test.ts deleted file mode 100644 index fa23fe6fad..0000000000 --- a/packages/opencode/test/llm-core/adapter.test.ts +++ /dev/null @@ -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({ - 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") - }) -})