mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1a95735b8 | |||
| a84babca73 | |||
| 7edaa05869 | |||
| 518d64d684 |
@@ -8,6 +8,7 @@ import { HttpTransport } from "./transport"
|
|||||||
import type { Transport, TransportRuntime } from "./transport"
|
import type { Transport, TransportRuntime } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
import { WebSocketExecutor } from "./transport"
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol } from "./protocol"
|
||||||
|
import type { CallOptions } from "./request-transform"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
||||||
@@ -47,7 +48,11 @@ export interface Route<Body, Prepared = unknown> {
|
|||||||
readonly body: RouteBody<Body>
|
readonly body: RouteBody<Body>
|
||||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||||
readonly model: (input: RouteMappedModelInput) => Model
|
readonly model: (input: RouteMappedModelInput) => Model
|
||||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
readonly prepareTransport: (
|
||||||
|
body: Body,
|
||||||
|
request: LLMRequest,
|
||||||
|
options?: CallOptions,
|
||||||
|
) => Effect.Effect<Prepared, LLMError>
|
||||||
readonly streamPrepared: (
|
readonly streamPrepared: (
|
||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
@@ -158,11 +163,11 @@ export interface Interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateMethod {
|
export interface GenerateMethod {
|
||||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||||
@@ -297,7 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
model: (input) => makeRouteModel(route, input),
|
model: (input) => makeRouteModel(route, input),
|
||||||
prepareTransport: (body, request) =>
|
prepareTransport: (body, request, options) =>
|
||||||
routeInput.transport.prepare({
|
routeInput.transport.prepare({
|
||||||
body,
|
body,
|
||||||
request,
|
request,
|
||||||
@@ -305,6 +310,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
auth: routeInput.auth ?? Auth.none,
|
auth: routeInput.auth ?? Auth.none,
|
||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
|
transformRequest: options?.transformRequest,
|
||||||
}),
|
}),
|
||||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
@@ -373,14 +379,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
|||||||
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
||||||
// validated provider body plus transport-private prepared data, but does not
|
// validated provider body plus transport-private prepared data, but does not
|
||||||
// execute transport.
|
// execute transport.
|
||||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: CallOptions) {
|
||||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||||
const route = resolved.model.route
|
const route = resolved.model.route
|
||||||
|
|
||||||
const body = yield* route.body
|
const body = yield* route.body
|
||||||
.from(resolved)
|
.from(resolved)
|
||||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||||
const prepared = yield* route.prepareTransport(body, resolved)
|
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request: resolved,
|
request: resolved,
|
||||||
@@ -403,17 +409,17 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: CallOptions) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request)
|
const compiled = yield* compile(request, options)
|
||||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const generateWith = (stream: Interface["stream"]) =>
|
const generateWith = (stream: Interface["stream"]) =>
|
||||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: CallOptions) {
|
||||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||||
const response = LLMResponse.complete(state)
|
const response = LLMResponse.complete(state)
|
||||||
if (response) return response
|
if (response) return response
|
||||||
return yield* ProviderShared.eventError(
|
return yield* ProviderShared.eventError(
|
||||||
@@ -425,17 +431,17 @@ const generateWith = (stream: Interface["stream"]) =>
|
|||||||
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
||||||
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||||
|
|
||||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
export function stream(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (yield* Service).stream(request)
|
return (yield* Service).stream(request, options)
|
||||||
}),
|
}),
|
||||||
) as Stream.Stream<LLMEvent, LLMError>
|
) as Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
export function generate(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
return yield* (yield* Service).generate(request)
|
return yield* (yield* Service).generate(request, options)
|
||||||
}) as Effect.Effect<LLMResponse, LLMError>
|
}) as Effect.Effect<LLMResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
Interface as LLMClientShape,
|
Interface as LLMClientShape,
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./client"
|
} from "./client"
|
||||||
|
export type { CallOptions, RequestData, RequestTransform, RequestValue } from "./request-transform"
|
||||||
export * from "./executor"
|
export * from "./executor"
|
||||||
export { Auth } from "./auth"
|
export { Auth } from "./auth"
|
||||||
export { AuthOptions } from "./auth-options"
|
export { AuthOptions } from "./auth-options"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Effect } from "effect"
|
||||||
|
|
||||||
|
export type RequestValue = null | boolean | number | string | RequestValue[] | { [key: string]: RequestValue }
|
||||||
|
|
||||||
|
export interface RequestData {
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly body: Record<string, RequestValue>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestTransform = (request: RequestData) => Effect.Effect<RequestData>
|
||||||
|
|
||||||
|
export interface CallOptions {
|
||||||
|
readonly transformRequest?: RequestTransform
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { render as renderEndpoint } from "../endpoint"
|
import { render as renderEndpoint } from "../endpoint"
|
||||||
@@ -6,6 +6,7 @@ import { Framing } from "../framing"
|
|||||||
import type { Transport, TransportPrepareInput } from "./index"
|
import type { Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||||
|
import type { RequestValue } from "../request-transform"
|
||||||
|
|
||||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||||
|
|
||||||
@@ -86,24 +87,61 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
|
|||||||
return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
|
return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
|
||||||
})
|
})
|
||||||
|
|
||||||
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
const isRequestValue = (value: unknown): value is RequestValue => {
|
||||||
|
if (value === null || ["boolean", "number", "string"].includes(typeof value)) return true
|
||||||
|
if (Array.isArray(value)) return value.every(isRequestValue)
|
||||||
|
return ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRequestBody = (value: unknown): value is Record<string, RequestValue> =>
|
||||||
|
ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
|
||||||
|
|
||||||
|
const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)
|
||||||
|
|
||||||
|
export const decodeRequestBody = (text: string) =>
|
||||||
|
decodeJson(text).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.invalidRequest("Request hooks require a JSON object body")),
|
||||||
|
Effect.flatMap((body) =>
|
||||||
|
isRequestBody(body)
|
||||||
|
? Effect.succeed(body)
|
||||||
|
: Effect.fail(ProviderShared.invalidRequest("Request hooks require a JSON object body")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const jsonRequestBaseParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const url = applyQuery(
|
const url = applyQuery(
|
||||||
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
|
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
|
||||||
input.request.http?.query,
|
input.request.http?.query,
|
||||||
)
|
)
|
||||||
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
|
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
jsonBody: body.jsonBody,
|
||||||
|
bodyText: body.bodyText,
|
||||||
|
headers: {
|
||||||
|
...input.headers?.({ request: input.request }),
|
||||||
|
...input.request.http?.headers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const base = yield* jsonRequestBaseParts(input)
|
||||||
|
const transformRequest = input.transformRequest
|
||||||
|
const transformed = transformRequest
|
||||||
|
? yield* transformRequest({ headers: base.headers, body: yield* decodeRequestBody(base.bodyText) })
|
||||||
|
: { headers: base.headers, body: base.jsonBody }
|
||||||
|
const bodyText = transformRequest ? ProviderShared.encodeJson(transformed.body) : base.bodyText
|
||||||
const headers = yield* Auth.toEffect(input.auth)({
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
request: input.request,
|
request: input.request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url,
|
url: base.url,
|
||||||
body: body.bodyText,
|
body: bodyText,
|
||||||
headers: Headers.fromInput({
|
headers: Headers.fromInput(transformed.headers),
|
||||||
...input.headers?.({ request: input.request }),
|
|
||||||
...input.request.http?.headers,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
|
return { url: base.url, jsonBody: transformed.body, bodyText, headers }
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface HttpJsonInput<_Body, Frame> {
|
export interface HttpJsonInput<_Body, Frame> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Auth } from "../auth"
|
|||||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
import type { Interface as RequestExecutorInterface } from "../executor"
|
||||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||||
import type { LLMError, LLMRequest } from "../../schema"
|
import type { LLMError, LLMRequest } from "../../schema"
|
||||||
|
import type { RequestTransform } from "../request-transform"
|
||||||
|
|
||||||
export interface TransportRuntime {
|
export interface TransportRuntime {
|
||||||
readonly http: RequestExecutorInterface
|
readonly http: RequestExecutorInterface
|
||||||
@@ -27,6 +28,7 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly auth: Auth.Definition
|
readonly auth: Auth.Definition
|
||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
|
readonly transformRequest?: RequestTransform
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http"
|
export * as HttpTransport from "./http"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
|
import { encodeJson } from "../../protocols/shared"
|
||||||
import { LLMError, TransportReason } from "../../schema"
|
import { LLMError, TransportReason } from "../../schema"
|
||||||
|
import { Auth } from "../auth"
|
||||||
import * as HttpTransport from "./http"
|
import * as HttpTransport from "./http"
|
||||||
import type { Transport } from "./index"
|
import type { Transport } from "./index"
|
||||||
|
|
||||||
@@ -228,13 +230,24 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
with: (patch) => json({ ...input, ...patch }),
|
with: (patch) => json({ ...input, ...patch }),
|
||||||
prepare: (prepareInput) =>
|
prepare: (prepareInput) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parts = yield* HttpTransport.jsonRequestParts({
|
const parts = yield* HttpTransport.jsonRequestBaseParts(prepareInput)
|
||||||
...prepareInput,
|
const message = input.encodeMessage(yield* input.toMessage(parts.jsonBody))
|
||||||
|
const transformRequest = prepareInput.transformRequest
|
||||||
|
const transformed = transformRequest
|
||||||
|
? yield* transformRequest({ headers: parts.headers, body: yield* HttpTransport.decodeRequestBody(message) })
|
||||||
|
: undefined
|
||||||
|
const bodyText = transformed ? encodeJson(transformed.body) : message
|
||||||
|
const headers = yield* Auth.toEffect(prepareInput.auth)({
|
||||||
|
request: prepareInput.request,
|
||||||
|
method: "POST",
|
||||||
|
url: parts.url,
|
||||||
|
body: bodyText,
|
||||||
|
headers: Headers.fromInput(transformed?.headers ?? parts.headers),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: yield* webSocketUrl(parts.url),
|
url: yield* webSocketUrl(parts.url),
|
||||||
headers: parts.headers,
|
headers,
|
||||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
message: bodyText,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) => {
|
frames: (prepared, _request, runtime) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, mergeProviderOptions } from "../src"
|
import { LLM, mergeProviderOptions } from "../src"
|
||||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||||
@@ -136,6 +136,41 @@ describe("request option precedence", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("transforms provider-native headers and JSON", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = OpenAIChat.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
|
.model({ id: "gpt-4o-mini" })
|
||||||
|
yield* LLMClient.stream(LLM.request({ model, prompt: "Say hello." }), {
|
||||||
|
transformRequest: (request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const body = { ...request.body, store: true }
|
||||||
|
delete body.stream_options
|
||||||
|
return {
|
||||||
|
headers: { ...request.headers, "x-plugin": "enabled" },
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(web.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(web.headers.get("x-plugin")).toBe("enabled")
|
||||||
|
expect(decodeJson(input.text)).toMatchObject({ store: true })
|
||||||
|
expect(decodeJson(input.text)).not.toHaveProperty("stream_options")
|
||||||
|
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = OpenAIChat.route
|
const model = OpenAIChat.route
|
||||||
|
|||||||
@@ -251,16 +251,34 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const response = yield* LLMClient.generate(
|
const text: string[] = []
|
||||||
|
yield* LLMClient.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||||
"gpt-4.1-mini",
|
"gpt-4.1-mini",
|
||||||
),
|
),
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
{
|
||||||
|
transformRequest: (request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(request.body.type).toBe("response.create")
|
||||||
|
expect(request.body.stream).toBeUndefined()
|
||||||
|
const body = { ...request.body, plugin: true }
|
||||||
|
delete body.store
|
||||||
|
return { headers: { ...request.headers, "x-plugin": "enabled" }, body }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Stream.runForEach((event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (LLMEvent.is.textDelta(event)) text.push(event.text)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||||
|
)
|
||||||
|
|
||||||
expect(response.text).toBe("Hi")
|
expect(text.join("")).toBe("Hi")
|
||||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||||
expect(closed).toBe(true)
|
expect(closed).toBe(true)
|
||||||
expect(sent).toHaveLength(1)
|
expect(sent).toHaveLength(1)
|
||||||
@@ -268,7 +286,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
type: "response.create",
|
type: "response.create",
|
||||||
model: "gpt-4.1-mini",
|
model: "gpt-4.1-mini",
|
||||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||||
store: false,
|
plugin: true,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as AISDK from "./aisdk"
|
export * as AISDK from "./aisdk"
|
||||||
|
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import type {
|
import type {
|
||||||
JSONSchema7,
|
JSONSchema7,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type UsageInput,
|
type UsageInput,
|
||||||
} from "@opencode-ai/ai"
|
} from "@opencode-ai/ai"
|
||||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
import { Auth, Endpoint, type AnyRoute, type CallOptions } from "@opencode-ai/ai/route"
|
||||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -40,6 +41,12 @@ type SDK = any
|
|||||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||||
|
type TransformRequest = NonNullable<CallOptions["transformRequest"]>
|
||||||
|
|
||||||
|
interface AISDKPrepared {
|
||||||
|
readonly call: LanguageModelV3CallOptions
|
||||||
|
readonly transformRequest?: TransformRequest
|
||||||
|
}
|
||||||
|
|
||||||
export interface SDKEvent {
|
export interface SDKEvent {
|
||||||
readonly model: ModelV2.Info
|
readonly model: ModelV2.Info
|
||||||
@@ -103,7 +110,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
function prepareOptions(model: ModelV2.Info, pkg: string, requests: AsyncLocalStorage<TransformRequest>) {
|
||||||
const projected = mapBodyToProviderOptions(model, pkg)
|
const projected = mapBodyToProviderOptions(model, pkg)
|
||||||
const options: Record<string, any> = {
|
const options: Record<string, any> = {
|
||||||
name: model.providerID,
|
name: model.providerID,
|
||||||
@@ -150,6 +157,21 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(opts.headers)
|
||||||
|
const transformRequest = requests.getStore()
|
||||||
|
if (transformRequest) {
|
||||||
|
if (typeof opts.body !== "string") throw new Error("Session request hooks require a JSON request body")
|
||||||
|
const prepared = await Effect.runPromise(
|
||||||
|
transformRequest({
|
||||||
|
headers: Object.fromEntries(headers.entries()),
|
||||||
|
body: JSON.parse(opts.body),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
opts.headers = prepared.headers
|
||||||
|
opts.body = JSON.stringify(prepared.body)
|
||||||
|
}
|
||||||
|
if (!transformRequest) opts.headers = headers
|
||||||
|
|
||||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||||
...opts,
|
...opts,
|
||||||
timeout: false,
|
timeout: false,
|
||||||
@@ -194,6 +216,7 @@ export const locationLayer = Layer.effect(
|
|||||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||||
const languages = new Map<string, LanguageModelV3>()
|
const languages = new Map<string, LanguageModelV3>()
|
||||||
const sdks = new Map<string, SDK>()
|
const sdks = new Map<string, SDK>()
|
||||||
|
const requests = new AsyncLocalStorage<TransformRequest>()
|
||||||
const functionIDs = new WeakMap<object, number>()
|
const functionIDs = new WeakMap<object, number>()
|
||||||
let nextFunctionID = 0
|
let nextFunctionID = 0
|
||||||
const cacheKey = (input: unknown) =>
|
const cacheKey = (input: unknown) =>
|
||||||
@@ -267,7 +290,7 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const packageName = ProviderV2.packageName(model.package)
|
const packageName = ProviderV2.packageName(model.package)
|
||||||
const options = prepareOptions(model, packageName)
|
const options = prepareOptions(model, packageName, requests)
|
||||||
const sdkKey = cacheKey({
|
const sdkKey = cacheKey({
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
package: packageName,
|
package: packageName,
|
||||||
@@ -292,7 +315,7 @@ export const locationLayer = Layer.effect(
|
|||||||
return language
|
return language
|
||||||
}),
|
}),
|
||||||
model: Effect.fn("AISDK.model")(function* (model) {
|
model: Effect.fn("AISDK.model")(function* (model) {
|
||||||
return modelFromLanguage(model, yield* service.language(model))
|
return modelFromLanguage(model, yield* service.language(model), requests)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return service
|
return service
|
||||||
@@ -301,7 +324,11 @@ export const locationLayer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = locationLayer
|
export const defaultLayer = locationLayer
|
||||||
|
|
||||||
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
function modelFromLanguage(
|
||||||
|
info: ModelV2.Info,
|
||||||
|
language: LanguageModelV3,
|
||||||
|
requests: AsyncLocalStorage<TransformRequest>,
|
||||||
|
) {
|
||||||
const packageName = ProviderV2.packageName(info.package!)
|
const packageName = ProviderV2.packageName(info.package!)
|
||||||
const projected = mapBodyToProviderOptions(info, packageName)
|
const projected = mapBodyToProviderOptions(info, packageName)
|
||||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||||
@@ -341,8 +368,13 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
|||||||
},
|
},
|
||||||
with: () => route,
|
with: () => route,
|
||||||
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||||
prepareTransport: (body) => Effect.succeed(body),
|
prepareTransport: (body, _request, options) =>
|
||||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
Effect.succeed({ call: body as LanguageModelV3CallOptions, transformRequest: options?.transformRequest }),
|
||||||
|
streamPrepared: (prepared) => {
|
||||||
|
const request = prepared as AISDKPrepared
|
||||||
|
if (!request.transformRequest) return streamLanguage(language, request.call)
|
||||||
|
return streamLanguage(language, request.call, requests, request.transformRequest)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
|
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
|
||||||
}
|
}
|
||||||
@@ -533,13 +565,21 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
|||||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
function streamLanguage(
|
||||||
|
language: LanguageModelV3,
|
||||||
|
options: LanguageModelV3CallOptions,
|
||||||
|
requests?: AsyncLocalStorage<TransformRequest>,
|
||||||
|
transformRequest?: TransformRequest,
|
||||||
|
) {
|
||||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
const state = { step: 0, toolNames: {} as Record<string, string> }
|
||||||
return Stream.concat(
|
return Stream.concat(
|
||||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => language.doStream(options),
|
try: () =>
|
||||||
|
requests && transformRequest
|
||||||
|
? requests.run(transformRequest, () => language.doStream(options))
|
||||||
|
: language.doStream(options),
|
||||||
catch: (error) => llmError("doStream", error),
|
catch: (error) => llmError("doStream", error),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface Domains {
|
|||||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
readonly has: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||||
|
domain: Domain,
|
||||||
|
name: Name,
|
||||||
|
) => boolean
|
||||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: Name,
|
name: Name,
|
||||||
@@ -60,7 +64,7 @@ const layer = Layer.effect(
|
|||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ register, trigger })
|
return Service.of({ has: (domain, name) => (callbacks.get(key(domain, name))?.length ?? 0) > 0, register, trigger })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
export * as SessionCompaction from "./compaction"
|
export * as SessionCompaction from "./compaction"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
|
import { LLM, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||||
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { llmClient } from "../effect/app-node-platform"
|
import { Client } from "@opencode-ai/util/client"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import { SessionModelHeaders } from "./model-headers"
|
import { SessionModelHeaders } from "./model-headers"
|
||||||
import { Client } from "@opencode-ai/util/client"
|
import { SessionModelStream } from "./model-stream"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { toSessionError } from "./to-session-error"
|
import { toSessionError } from "./to-session-error"
|
||||||
@@ -63,9 +65,7 @@ type Settings = {
|
|||||||
type Dependencies = {
|
type Dependencies = {
|
||||||
readonly client: string
|
readonly client: string
|
||||||
readonly events: EventV2.Interface
|
readonly events: EventV2.Interface
|
||||||
readonly llm: {
|
readonly llm: SessionModelStream.Interface
|
||||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
|
||||||
}
|
|
||||||
readonly models: SessionRunnerModel.Interface
|
readonly models: SessionRunnerModel.Interface
|
||||||
readonly config: Settings
|
readonly config: Settings
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,9 @@ type Dependencies = {
|
|||||||
export type AutoInput = {
|
export type AutoInput = {
|
||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly model: Model
|
readonly model: LLMRequest["model"]
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly modelRef: Model.Ref
|
||||||
readonly cost: ModelV2.Info["cost"]
|
readonly cost: ModelV2.Info["cost"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +83,14 @@ export type ManualInput = {
|
|||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly inputID: SessionMessage.ID
|
readonly inputID: SessionMessage.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
type Plan = {
|
type Plan = {
|
||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly model: Model
|
readonly model: LLMRequest["model"]
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly modelRef: Model.Ref
|
||||||
readonly cost: ModelV2.Info["cost"]
|
readonly cost: ModelV2.Info["cost"]
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
readonly prompt: string
|
readonly prompt: string
|
||||||
@@ -257,14 +262,17 @@ const make = (dependencies: Dependencies) => {
|
|||||||
: Effect.void,
|
: Effect.void,
|
||||||
)
|
)
|
||||||
yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream({
|
||||||
LLM.request({
|
sessionID: plan.session.id,
|
||||||
|
agent: plan.agent,
|
||||||
|
model: plan.modelRef,
|
||||||
|
request: LLM.request({
|
||||||
model: plan.model,
|
model: plan.model,
|
||||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
|
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
|
||||||
messages: [Message.user(plan.prompt)],
|
messages: [Message.user(plan.prompt)],
|
||||||
tools: [],
|
tools: [],
|
||||||
}),
|
}),
|
||||||
)
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event))
|
if (LLMEvent.is.providerError(event))
|
||||||
@@ -331,6 +339,8 @@ const make = (dependencies: Dependencies) => {
|
|||||||
session: input.session,
|
session: input.session,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
cost: input.cost,
|
cost: input.cost,
|
||||||
|
agent: input.agent,
|
||||||
|
modelRef: input.modelRef,
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
...content,
|
...content,
|
||||||
})
|
})
|
||||||
@@ -380,6 +390,8 @@ const make = (dependencies: Dependencies) => {
|
|||||||
session: input.session,
|
session: input.session,
|
||||||
model: resolved.model,
|
model: resolved.model,
|
||||||
cost: resolved.cost,
|
cost: resolved.cost,
|
||||||
|
agent: input.agent,
|
||||||
|
modelRef: resolved.ref,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
inputID: input.inputID,
|
inputID: input.inputID,
|
||||||
...content,
|
...content,
|
||||||
@@ -396,7 +408,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* SessionModelStream.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const client = yield* Client.Name
|
const client = yield* Client.Name
|
||||||
@@ -407,5 +419,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node],
|
deps: [EventV2.node, SessionModelStream.node, Config.node, SessionRunnerModel.node, Client.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export * as SessionModelStream from "./model-stream"
|
||||||
|
|
||||||
|
import { LLMClient, type LLMError, type LLMEvent, type LLMRequest } from "@opencode-ai/ai"
|
||||||
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
|
import { llmClient } from "../effect/app-node-platform"
|
||||||
|
import { PluginHooks } from "../plugin/hooks"
|
||||||
|
|
||||||
|
export interface Input {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly model: Model.Ref
|
||||||
|
readonly request: LLMRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly stream: (input: Input) => Stream.Stream<LLMEvent, LLMError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelStream") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const llm = yield* LLMClient.Service
|
||||||
|
return Service.of({
|
||||||
|
stream: (input) => {
|
||||||
|
if (!hooks.has("session", "request")) return llm.stream(input.request)
|
||||||
|
return llm.stream(input.request, {
|
||||||
|
transformRequest: (request) =>
|
||||||
|
hooks
|
||||||
|
.trigger("session", "request", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
})
|
||||||
|
.pipe(Effect.map((event) => ({ headers: event.headers, body: event.body }))),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [PluginHooks.node, llmClient] })
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
export * as SessionRunnerLLM from "./llm"
|
export * as SessionRunnerLLM from "./llm"
|
||||||
|
|
||||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
import { LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
|
import { AgentV2 } from "../../agent"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { PermissionV2 } from "../../permission"
|
import { PermissionV2 } from "../../permission"
|
||||||
import { QuestionTool } from "../../tool/question"
|
import { QuestionTool } from "../../tool/question"
|
||||||
@@ -14,6 +15,7 @@ import { SessionContext } from "../context"
|
|||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionPending } from "../pending"
|
import { SessionPending } from "../pending"
|
||||||
import { SessionModelRequest } from "../model-request"
|
import { SessionModelRequest } from "../model-request"
|
||||||
|
import { SessionModelStream } from "../model-stream"
|
||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
@@ -22,7 +24,6 @@ import { Service } from "./index"
|
|||||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { llmClient } from "../../effect/app-node-platform"
|
|
||||||
import { StepFailedError } from "../error"
|
import { StepFailedError } from "../error"
|
||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
@@ -32,7 +33,7 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* SessionModelStream.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const context = yield* SessionContext.Service
|
const context = yield* SessionContext.Service
|
||||||
const modelRequests = yield* SessionModelRequest.Service
|
const modelRequests = yield* SessionModelRequest.Service
|
||||||
@@ -40,6 +41,7 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||||
@@ -102,7 +104,14 @@ const layer = Layer.effect(
|
|||||||
const agent = loaded.agent
|
const agent = loaded.agent
|
||||||
const resolved = loaded.model
|
const resolved = loaded.model
|
||||||
const model = resolved.model
|
const model = resolved.model
|
||||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
const compactionInput = {
|
||||||
|
session,
|
||||||
|
messages: loaded.messages,
|
||||||
|
model,
|
||||||
|
agent: agent.id,
|
||||||
|
modelRef: resolved.ref,
|
||||||
|
cost: resolved.cost,
|
||||||
|
}
|
||||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||||
const compacted = yield* compaction.compact(compactionInput)
|
const compacted = yield* compaction.compact(compactionInput)
|
||||||
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||||
@@ -132,67 +141,69 @@ const layer = Layer.effect(
|
|||||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
const providerStream = llm.stream(prepared.request).pipe(
|
const providerStream = llm
|
||||||
Stream.runForEach((event) =>
|
.stream({ sessionID: session.id, agent: agent.id, model: resolved.ref, request: prepared.request })
|
||||||
Effect.gen(function* () {
|
.pipe(
|
||||||
if (overflowFailure || publisher.hasProviderError()) return
|
Stream.runForEach((event) =>
|
||||||
if (LLMEvent.is.providerError(event)) {
|
Effect.gen(function* () {
|
||||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
if (overflowFailure || publisher.hasProviderError()) return
|
||||||
overflowFailure = event
|
if (LLMEvent.is.providerError(event)) {
|
||||||
|
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||||
|
overflowFailure = event
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* publish(event)
|
||||||
|
if (LLMEvent.is.toolInputError(event)) {
|
||||||
|
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||||
yield* publish(event)
|
const tool = prepared.resolveToolCall(event.name)
|
||||||
if (LLMEvent.is.toolInputError(event)) {
|
if (tool.type === "reject") {
|
||||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
needsContinuation = true
|
||||||
const tool = prepared.resolveToolCall(event.name)
|
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||||
if (tool.type === "reject") {
|
ownedToolFibers.push(
|
||||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
yield* Effect.uninterruptibleMask((restore) =>
|
||||||
return
|
restore(
|
||||||
}
|
tool.settle({
|
||||||
needsContinuation = true
|
sessionID: session.id,
|
||||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
agent: agent.id,
|
||||||
ownedToolFibers.push(
|
messageID: assistantMessageID,
|
||||||
yield* Effect.uninterruptibleMask((restore) =>
|
call: event,
|
||||||
restore(
|
progress: (update) =>
|
||||||
tool.settle({
|
serialized(
|
||||||
sessionID: session.id,
|
events.publish(SessionEvent.Tool.Progress, {
|
||||||
agent: agent.id,
|
sessionID: session.id,
|
||||||
messageID: assistantMessageID,
|
assistantMessageID,
|
||||||
call: event,
|
callID: event.id,
|
||||||
progress: (update) =>
|
structured: { ...update.structured },
|
||||||
serialized(
|
content: [...update.content],
|
||||||
events.publish(SessionEvent.Tool.Progress, {
|
}),
|
||||||
sessionID: session.id,
|
),
|
||||||
assistantMessageID,
|
}),
|
||||||
callID: event.id,
|
).pipe(
|
||||||
structured: { ...update.structured },
|
Effect.flatMap((settlement) =>
|
||||||
content: [...update.content],
|
publish(
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
result: settlement.result,
|
||||||
|
output: settlement.output,
|
||||||
}),
|
}),
|
||||||
|
settlement.error,
|
||||||
),
|
),
|
||||||
}),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((settlement) =>
|
|
||||||
publish(
|
|
||||||
LLMEvent.toolResult({
|
|
||||||
id: event.id,
|
|
||||||
name: event.name,
|
|
||||||
result: settlement.result,
|
|
||||||
output: settlement.output,
|
|
||||||
}),
|
|
||||||
settlement.error,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
).pipe(FiberSet.run(toolFibers)),
|
||||||
).pipe(FiberSet.run(toolFibers)),
|
)
|
||||||
)
|
}),
|
||||||
}),
|
),
|
||||||
),
|
Effect.ensuring(serialized(publisher.flush())),
|
||||||
Effect.ensuring(serialized(publisher.flush())),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||||
@@ -239,8 +250,7 @@ const layer = Layer.effect(
|
|||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
(yield* restore(recoverOverflow(compactionInput))).status === "completed"
|
||||||
.status === "completed"
|
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
@@ -413,8 +423,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compacted = yield* restore(
|
const compacted = yield* restore(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const agent = yield* agents.select(session.agent)
|
||||||
return yield* compaction.compactManual({
|
return yield* compaction.compactManual({
|
||||||
session,
|
session,
|
||||||
|
agent: agent.id,
|
||||||
messages: yield* store.context(sessionID),
|
messages: yield* store.context(sessionID),
|
||||||
inputID: pending.id,
|
inputID: pending.id,
|
||||||
})
|
})
|
||||||
@@ -493,12 +505,13 @@ export const node = makeLocationNode({
|
|||||||
layer,
|
layer,
|
||||||
deps: [
|
deps: [
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
llmClient,
|
SessionModelStream.node,
|
||||||
SessionContext.node,
|
SessionContext.node,
|
||||||
SessionModelRequest.node,
|
SessionModelRequest.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
|
AgentV2.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export * as SessionTitle from "./title"
|
export * as SessionTitle from "./title"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, LLMEvent, Message } from "@opencode-ai/ai"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Client } from "@opencode-ai/util/client"
|
import { Client } from "@opencode-ai/util/client"
|
||||||
import { llmClient } from "../effect/app-node-platform"
|
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionHistory } from "./history"
|
import { SessionHistory } from "./history"
|
||||||
import { SessionModelHeaders } from "./model-headers"
|
import { SessionModelHeaders } from "./model-headers"
|
||||||
|
import { SessionModelStream } from "./model-stream"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionUsage } from "./usage"
|
import { SessionUsage } from "./usage"
|
||||||
@@ -20,9 +20,7 @@ const MAX_LENGTH = 100
|
|||||||
type Dependencies = {
|
type Dependencies = {
|
||||||
readonly client: string
|
readonly client: string
|
||||||
readonly events: EventV2.Interface
|
readonly events: EventV2.Interface
|
||||||
readonly llm: {
|
readonly llm: SessionModelStream.Interface
|
||||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
|
||||||
}
|
|
||||||
readonly agents: AgentV2.Interface
|
readonly agents: AgentV2.Interface
|
||||||
readonly models: SessionRunnerModel.Interface
|
readonly models: SessionRunnerModel.Interface
|
||||||
}
|
}
|
||||||
@@ -65,15 +63,18 @@ const make = (dependencies: Dependencies) => {
|
|||||||
: Effect.void,
|
: Effect.void,
|
||||||
)
|
)
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream({
|
||||||
LLM.request({
|
sessionID: session.id,
|
||||||
|
agent: agent.id,
|
||||||
|
model: resolved.ref,
|
||||||
|
request: LLM.request({
|
||||||
model: resolved.model,
|
model: resolved.model,
|
||||||
http: { headers: SessionModelHeaders.make(session, dependencies.client) },
|
http: { headers: SessionModelHeaders.make(session, dependencies.client) },
|
||||||
system: agent.system,
|
system: agent.system,
|
||||||
messages: [Message.user(firstUser.text)],
|
messages: [Message.user(firstUser.text)],
|
||||||
tools: [],
|
tools: [],
|
||||||
}),
|
}),
|
||||||
)
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event)) failed = true
|
||||||
@@ -108,7 +109,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* SessionModelStream.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
@@ -123,5 +124,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
|
deps: [EventV2.node, SessionModelStream.node, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
|||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
import { LLMClient, RequestExecutor, type RequestData } from "@opencode-ai/ai/route"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(AISDK.locationLayer)
|
const it = testEffect(
|
||||||
|
Layer.merge(
|
||||||
|
AISDK.locationLayer,
|
||||||
|
LLMClient.layer.pipe(Layer.provide(Layer.mock(RequestExecutor.Service)({ execute: () => Effect.die("unused") }))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
@@ -99,6 +104,64 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("isolates request transforms across cached AI SDK streams", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
|
const upstream: Array<{ headers: Headers; body: Record<string, unknown> }> = []
|
||||||
|
let sdkFetch: typeof fetch | undefined
|
||||||
|
const input = model("test-sdk")
|
||||||
|
if (!input.settings) return yield* Effect.die("AI SDK test model settings are missing")
|
||||||
|
Object.assign(input.settings, {
|
||||||
|
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||||
|
upstream.push({
|
||||||
|
headers: new Headers(init?.headers),
|
||||||
|
body: JSON.parse(String(init?.body)),
|
||||||
|
})
|
||||||
|
return new Response()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
yield* aisdk.hook.sdk((event) => {
|
||||||
|
sdkFetch = event.options.fetch
|
||||||
|
event.sdk = {
|
||||||
|
languageModel: () => ({
|
||||||
|
doStream: async (options: LanguageModelV3CallOptions) => {
|
||||||
|
if (!sdkFetch) throw new Error("AI SDK fetch was not installed")
|
||||||
|
await sdkFetch("https://provider.test/model", {
|
||||||
|
method: "POST",
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
Object.entries(options.headers ?? {}).filter(
|
||||||
|
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: JSON.stringify({ model: "api-model", remove: true }),
|
||||||
|
})
|
||||||
|
return { stream: new ReadableStream({ start: (controller) => controller.close() }) }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const resolved = yield* aisdk.model(input)
|
||||||
|
const llm = yield* LLMClient.Service
|
||||||
|
const run = (session: string) =>
|
||||||
|
llm
|
||||||
|
.stream(LLM.request({ model: resolved, prompt: session }), {
|
||||||
|
transformRequest: (request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const body: RequestData["body"] = { ...request.body, session }
|
||||||
|
delete body.remove
|
||||||
|
return { headers: { ...request.headers, "x-session": session }, body }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.pipe(Stream.runDrain)
|
||||||
|
|
||||||
|
yield* Effect.all([run("one"), run("two")], { concurrency: "unbounded" })
|
||||||
|
|
||||||
|
expect(upstream.map((request) => request.headers.get("x-session")).toSorted()).toEqual(["one", "two"])
|
||||||
|
expect(upstream.map((request) => request.body.session).toSorted()).toEqual(["one", "two"])
|
||||||
|
expect(upstream.every((request) => !("remove" in request.body))).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
|
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Agent } from "@opencode-ai/schema/agent"
|
|||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { PluginHooks } from "../src/plugin/hooks"
|
import { PluginHooks } from "../src/plugin/hooks"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -28,7 +29,7 @@ describe("PluginHooks", () => {
|
|||||||
event.messages = [Message.user("changed")]
|
event.messages = [Message.user("changed")]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const event = {
|
const event: SessionHooks["context"] = {
|
||||||
sessionID: Session.ID.make("ses_hooks"),
|
sessionID: Session.ID.make("ses_hooks"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
@@ -42,4 +43,34 @@ describe("PluginHooks", () => {
|
|||||||
expect(event.messages).toEqual([Message.user("changed")])
|
expect(event.messages).toEqual([Message.user("changed")])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("registers session request hooks and triggers them sequentially", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
yield* hooks.register("session", "request", (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.headers.authorization = "Bearer changed"
|
||||||
|
delete event.body.max_output_tokens
|
||||||
|
event.body.store = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* hooks.register("session", "request", (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.headers = { ...event.headers, "x-store": String(event.body.store) }
|
||||||
|
event.body = { input: event.body.input ?? [] }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const event: SessionHooks["request"] = {
|
||||||
|
sessionID: Session.ID.make("ses_request_hooks"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
headers: { authorization: "Bearer original" },
|
||||||
|
body: { input: ["hello"], max_output_tokens: 1024 },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
||||||
|
expect(event.headers).toEqual({ authorization: "Bearer changed", "x-store": "false" })
|
||||||
|
expect(event.body).toEqual({ input: ["hello"] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -218,6 +218,37 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("forwards session request hooks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
Plugin.define({
|
||||||
|
id: "promise-session-request",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("request", (event) => {
|
||||||
|
event.headers["x-plugin"] = "promise"
|
||||||
|
delete event.body.max_output_tokens
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const event: SessionHooks["request"] = {
|
||||||
|
sessionID: SessionV2.ID.make("ses_promise_session_request"),
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
headers: {},
|
||||||
|
body: { max_output_tokens: 1024 },
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "request", event)
|
||||||
|
|
||||||
|
expect(event.headers).toEqual({ "x-plugin": "promise" })
|
||||||
|
expect(event.body).toEqual({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("disposes a hook registration on request", () =>
|
it.effect("disposes a hook registration on request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
@@ -181,6 +182,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||||||
expect(
|
expect(
|
||||||
yield* compaction.compactManual({
|
yield* compaction.compactManual({
|
||||||
session,
|
session,
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
messages: [userMessage],
|
messages: [userMessage],
|
||||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { expect } from "bun:test"
|
||||||
|
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||||
|
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||||
|
import type { RequestData } from "@opencode-ai/ai/route"
|
||||||
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
import { Effect, Layer, Stream } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
|
import { SessionModelStream } from "@opencode-ai/core/session/model-stream"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const prepared: RequestData[] = []
|
||||||
|
const client = Layer.mock(LLMClient.Service)({
|
||||||
|
prepare: () => Effect.die("unused"),
|
||||||
|
generate: () => Effect.die("unused"),
|
||||||
|
stream: (_request, options) =>
|
||||||
|
Stream.unwrap(
|
||||||
|
options?.transformRequest
|
||||||
|
? options.transformRequest({ headers: { original: "true" }, body: { remove: true } }).pipe(
|
||||||
|
Effect.map((request) => {
|
||||||
|
prepared.push(request)
|
||||||
|
return Stream.empty
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.die("request transform was not provided"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([PluginHooks.node, SessionModelStream.node]), [[llmClient, client]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("forwards session identity and applies request hook mutations", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
prepared.length = 0
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const stream = yield* SessionModelStream.Service
|
||||||
|
const sessionID = Session.ID.make("ses_model_stream")
|
||||||
|
const agent = Agent.ID.make("build")
|
||||||
|
const model = Model.Ref.make({
|
||||||
|
providerID: Provider.ID.make("test"),
|
||||||
|
id: Model.ID.make("catalog-model"),
|
||||||
|
})
|
||||||
|
yield* hooks.register("session", "request", (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(event.sessionID).toBe(sessionID)
|
||||||
|
expect(event.agent).toBe(agent)
|
||||||
|
expect(event.model).toEqual(model)
|
||||||
|
event.headers["x-plugin"] = "enabled"
|
||||||
|
delete event.body.remove
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* stream
|
||||||
|
.stream({
|
||||||
|
sessionID,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
request: LLM.request({
|
||||||
|
model: OpenAIChat.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" } })
|
||||||
|
.model({ id: "api-model" }),
|
||||||
|
prompt: "Hello",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.pipe(Stream.runDrain)
|
||||||
|
|
||||||
|
expect(prepared).toEqual([{ headers: { original: "true", "x-plugin": "enabled" }, body: {} }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -4,8 +4,11 @@ import type { Agent } from "@opencode-ai/schema/agent"
|
|||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { JsonSchema } from "effect"
|
import type { JsonSchema } from "effect"
|
||||||
|
import type { SessionRequest } from "../session.js"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export type { RequestBody, RequestValue, SessionRequest } from "../session.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
@@ -17,6 +20,7 @@ export interface SessionContext {
|
|||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
|
readonly request: SessionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import type { Agent } from "@opencode-ai/schema/agent"
|
|||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { JsonSchema } from "effect"
|
import type { JsonSchema } from "effect"
|
||||||
|
import type { SessionRequest } from "../session.js"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export type { RequestBody, RequestValue, SessionRequest } from "../session.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
@@ -17,6 +20,7 @@ export interface SessionContext {
|
|||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
|
readonly request: SessionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestData } from "@opencode-ai/ai/route"
|
||||||
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
||||||
|
export type RequestValue = RequestData["body"][string]
|
||||||
|
|
||||||
|
export type RequestBody = RequestData["body"]
|
||||||
|
|
||||||
|
export interface SessionRequest {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly model: Model.Ref
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: RequestBody
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user