mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
feat(plugin): add session request hook
This commit is contained in:
committed by
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent
ad8e6b1fb6
commit
0e864b223f
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import type { RequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -155,6 +155,7 @@ export interface Interface {
|
||||
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
readonly withRequestTransform: (transform: RequestTransform) => Interface
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -411,6 +412,22 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
||||
}),
|
||||
)
|
||||
|
||||
const makeClient = (runtime: TransportRuntime): Interface => {
|
||||
const stream = streamRequestWith(runtime)
|
||||
return {
|
||||
prepare: prepareWith as Interface["prepare"],
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
withRequestTransform: (transform) =>
|
||||
makeClient({
|
||||
...runtime,
|
||||
transformRequest: runtime.transformRequest
|
||||
? (request) => runtime.transformRequest!(request).pipe(Effect.flatMap(transform))
|
||||
: transform,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
@@ -449,11 +466,12 @@ export const streamRequest = (request: LLMRequest) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
})
|
||||
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
|
||||
return Service.of(
|
||||
makeClient({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type { Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { RequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render as renderEndpoint } from "../endpoint"
|
||||
import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
import { InvalidRequestReason, LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -130,23 +130,36 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
(runtime.transformRequest
|
||||
? HttpClientRequest.toWeb(prepared.request).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new LLMError({
|
||||
module: "HttpTransport",
|
||||
method: "frames",
|
||||
reason: new InvalidRequestReason({ message: error.message }),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(runtime.transformRequest),
|
||||
Effect.map((request) => HttpClientRequest.fromWeb(request.clone() as Request)),
|
||||
Effect.flatMap(runtime.http.execute),
|
||||
)
|
||||
: runtime.http.execute(prepared.request)
|
||||
).pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import type { LLMError, LLMRequest } from "../../schema"
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
readonly transformRequest?: RequestTransform
|
||||
}
|
||||
|
||||
export type RequestTransform = (request: Request) => Effect.Effect<Request, LLMError>
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||
|
||||
@@ -690,4 +690,33 @@ describe("OpenAI Chat route", () => {
|
||||
expect(events.map((event) => event.type)).toEqual(["step-start"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("transforms the serialized HTTP request before dispatch", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const seen: string[] = []
|
||||
yield* llm
|
||||
.withRequestTransform((request) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(request.url)
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-hook", "enabled")
|
||||
return new Request(request, { headers })
|
||||
}),
|
||||
)
|
||||
.stream(request)
|
||||
.pipe(Stream.runDrain)
|
||||
|
||||
expect(seen).toEqual(["https://api.openai.test/v1/chat/completions"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(input.request.headers["x-hook"]).toBe("enabled")
|
||||
return Effect.succeed(
|
||||
input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, type AnyRoute, type TransportRuntime } from "@opencode-ai/ai/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -39,6 +40,7 @@ type SDK = any
|
||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
const requestTransform = new AsyncLocalStorage<TransportRuntime["transformRequest"]>()
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
@@ -149,10 +151,20 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
})
|
||||
const request = new Request(input as Request, opts)
|
||||
const transform = requestTransform.getStore()
|
||||
const transformed = transform ? await Effect.runPromise(transform(request)) : request
|
||||
const upstream = typeof customFetch === "function" ? customFetch : fetch
|
||||
const res = transform
|
||||
? await upstream(transformed.url, {
|
||||
...opts,
|
||||
method: transformed.method,
|
||||
headers: transformed.headers,
|
||||
body: transformed === request || transformed.body === null ? opts.body : await transformed.clone().text(),
|
||||
signal: transformed.signal,
|
||||
timeout: false,
|
||||
})
|
||||
: await upstream(input, { ...opts, timeout: false })
|
||||
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}
|
||||
@@ -341,7 +353,8 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
with: () => route,
|
||||
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
streamPrepared: (prepared, _request, runtime) =>
|
||||
streamLanguage(language, prepared as LanguageModelV3CallOptions, runtime.transformRequest),
|
||||
}
|
||||
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
|
||||
}
|
||||
@@ -532,13 +545,17 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
}
|
||||
|
||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
||||
function streamLanguage(
|
||||
language: LanguageModelV3,
|
||||
options: LanguageModelV3CallOptions,
|
||||
transform: TransportRuntime["transformRequest"],
|
||||
) {
|
||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
||||
return Stream.concat(
|
||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
try: () => requestTransform.run(transform, () => language.doStream(options)),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface Domains {
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
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]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
@@ -60,7 +64,7 @@ const layer = Layer.effect(
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({ register, trigger })
|
||||
return Service.of({ has: (domain, name) => callbacks.has(key(domain, name)), register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, LLMEvent, Message, type Model } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRequestHook } from "./request-hook"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { toSessionError } from "./to-session-error"
|
||||
@@ -59,11 +62,10 @@ type Settings = {
|
||||
|
||||
type Dependencies = {
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
readonly llm: LLMClientShape
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly config: Settings
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -240,7 +242,7 @@ const make = (dependencies: Dependencies) => {
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
yield* dependencies.llm
|
||||
yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, plan.session.id)
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
@@ -372,12 +374,13 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
return make({ events, llm, models, config: settings(yield* config.entries()) })
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ events, llm, models, config: settings(yield* config.entries()), hooks })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
|
||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export * as SessionRequestHook from "./request-hook"
|
||||
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { Effect } from "effect"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, sessionID: SessionSchema.ID) =>
|
||||
hooks.has("session", "request")
|
||||
? llm.withRequestTransform((request) =>
|
||||
hooks.trigger("session", "request", { sessionID, request }).pipe(Effect.map((event) => event.request)),
|
||||
)
|
||||
: llm
|
||||
@@ -8,6 +8,7 @@ import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
import { QuestionTool } from "../../tool/question"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { InstructionState } from "../instruction-state"
|
||||
@@ -15,6 +16,7 @@ import { SessionCompaction } from "../compaction"
|
||||
import { SessionContext } from "../context"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionPending } from "../pending"
|
||||
import { SessionRequestHook } from "../request-hook"
|
||||
import { SessionModelRequest } from "../model-request"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
@@ -65,6 +67,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
// 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
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
@@ -157,7 +160,7 @@ const layer = Layer.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))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
const providerStream = SessionRequestHook.client(llm, hooks, session.id).stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
@@ -506,5 +509,6 @@ export const node = makeLocationNode({
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRequestHook } from "./request-hook"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
@@ -17,11 +20,10 @@ const MAX_LENGTH = 100
|
||||
|
||||
type Dependencies = {
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
readonly llm: LLMClientShape
|
||||
readonly agents: AgentV2.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -51,7 +53,7 @@ const make = (dependencies: Dependencies) => {
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
const streamed = yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, session.id)
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
@@ -93,7 +95,8 @@ export const layer = Layer.effect(
|
||||
const agents = yield* AgentV2.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const database = yield* Database.Service
|
||||
const title = make({ events, llm, agents, models })
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ events, llm, agents, models, hooks })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
||||
})
|
||||
@@ -103,5 +106,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
|
||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
@@ -19,6 +19,64 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
it.effect("applies request transforms to AI SDK fetch calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let received: Request | undefined
|
||||
const input = model("test-sdk")
|
||||
Object.defineProperty(input, "settings", {
|
||||
value: {
|
||||
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
received = new Request(input as Request, init)
|
||||
return new Response()
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () => ({
|
||||
doStream: async () => {
|
||||
await event.options.fetch("https://provider.test/generate", { method: "POST", body: "{}" })
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const resolved = yield* aisdk.model(input)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const body = yield* resolved.route.body.from(request)
|
||||
const prepared = yield* resolved.route.prepareTransport(body, request)
|
||||
yield* resolved.route
|
||||
.streamPrepared(prepared, request, {
|
||||
http: { execute: () => Effect.die("unused") },
|
||||
transformRequest: (request) =>
|
||||
Effect.sync(() => {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-hook", "enabled")
|
||||
return new Request(request, { headers })
|
||||
}),
|
||||
})
|
||||
.pipe(Stream.runDrain)
|
||||
|
||||
expect(received?.url).toBe("https://provider.test/generate")
|
||||
expect(received?.headers.get("x-hook")).toBe("enabled")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -42,4 +42,24 @@ describe("PluginHooks", () => {
|
||||
expect(event.messages).toEqual([Message.user("changed")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows session request hooks to replace the raw request", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
expect(hooks.has("session", "request")).toBe(false)
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.request = new Request(event.request, { headers: { "x-hook": "enabled" } })
|
||||
}),
|
||||
)
|
||||
expect(hooks.has("session", "request")).toBe(true)
|
||||
const event = {
|
||||
sessionID: Session.ID.make("ses_request_hook"),
|
||||
request: new Request("https://example.com"),
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
||||
expect(event.request.headers.get("x-hook")).toBe("enabled")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -40,14 +41,16 @@ const projects = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientShape: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withRequestTransform: () => clientShape,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientShape)
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const locations = Layer.effect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -31,14 +32,16 @@ const model = Model.make({
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
})
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientShape: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withRequestTransform: () => clientShape,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientShape)
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||
|
||||
@@ -89,9 +89,7 @@ let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsReady = 5
|
||||
let activeToolExecutions = 0
|
||||
let maxActiveToolExecutions = 0
|
||||
const client = Layer.succeed(
|
||||
LLMClient.Service,
|
||||
LLMClient.Service.of({
|
||||
const clientValue: LLMClientShape = LLMClient.Service.of({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: ((request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
@@ -113,8 +111,9 @@ const client = Layer.succeed(
|
||||
)
|
||||
}) as unknown as LLMClientShape["stream"],
|
||||
generate: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
withRequestTransform: () => clientValue,
|
||||
})
|
||||
const client = Layer.succeed(LLMClient.Service, clientValue)
|
||||
const reply = {
|
||||
stop: () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -29,14 +30,16 @@ const model = Model.make({
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
})
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientShape: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withRequestTransform: () => clientShape,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientShape)
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||
})
|
||||
|
||||
@@ -92,6 +92,19 @@ yield *
|
||||
)
|
||||
```
|
||||
|
||||
The serialized provider request is also mutable before it is sent:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
ctx.session.hook("request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.request = new Request(event.request, {
|
||||
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
|
||||
})
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Reloading A Domain
|
||||
|
||||
When data captured by a transform changes, reload the affected domain:
|
||||
|
||||
@@ -15,8 +15,14 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest {
|
||||
readonly sessionID: Session.ID
|
||||
request: Request
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -94,6 +94,16 @@ await ctx.session.hook("context", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
The serialized provider request is also mutable before it is sent:
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("request", (event) => {
|
||||
event.request = new Request(event.request, {
|
||||
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Promise tools use plain object declarations with async executors:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -15,8 +15,14 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest {
|
||||
readonly sessionID: Session.ID
|
||||
request: Request
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt"> & {
|
||||
|
||||
Reference in New Issue
Block a user