feat(llm): move core to package
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user