feat(plugin): wire session request hook
This commit is contained in:
parent
518d64d684
commit
7edaa05869
17 changed files with 447 additions and 73 deletions
|
|
@ -3,12 +3,17 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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 { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "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> = {}) =>
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
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(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messages: [userMessage],
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
|
|
|
|||
73
packages/core/test/session-model-stream.test.ts
Normal file
73
packages/core/test/session-model-stream.test.ts
Normal file
|
|
@ -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/core/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: {} }])
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue