diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 067292329b..6316643c07 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -155,8 +155,11 @@ export interface Interface { readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> readonly stream: StreamMethod readonly generate: GenerateMethod + readonly withOptionsTransform: (transform: OptionsTransform) => Interface } +export type OptionsTransform = (request: LLMRequest) => Effect.Effect + export interface StreamMethod { (request: LLMRequest): Stream.Stream } @@ -373,43 +376,59 @@ export function make( // `compile` is the important boundary: it turns a common `LLMRequest` into a // validated provider body plus transport-private prepared data, but does not // execute transport. -const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { - const resolved = applyCachePolicy(resolveRequestOptions(request)) - const route = resolved.model.route +const compileWith = (transform?: OptionsTransform) => + Effect.fn("LLM.compile")(function* (request: LLMRequest) { + const options = resolveRequestOptions(request) + const resolved = applyCachePolicy(transform ? yield* transform(options) : options) + const route = resolved.model.route - const body = yield* route.body - .from(resolved) - .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) - const prepared = yield* route.prepareTransport(body, resolved) + const body = yield* route.body + .from(resolved) + .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) + const prepared = yield* route.prepareTransport(body, resolved) - return { - request: resolved, - route, - body, - prepared, - } -}) - -const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { - const compiled = yield* compile(request) - - return new PreparedRequest({ - id: compiled.request.id ?? "request", - route: compiled.route.id, - protocol: compiled.route.protocol, - model: compiled.request.model, - body: compiled.body, - metadata: { transport: compiled.route.transport.id }, + return { + request: resolved, + route, + body, + prepared, + } }) -}) -const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => - Stream.unwrap( - Effect.gen(function* () { - const compiled = yield* compile(request) - return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) - }), - ) +const prepareWith = (compile: ReturnType) => + Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { + const compiled = yield* compile(request) + + return new PreparedRequest({ + id: compiled.request.id ?? "request", + route: compiled.route.id, + protocol: compiled.route.protocol, + model: compiled.request.model, + body: compiled.body, + metadata: { transport: compiled.route.transport.id }, + }) + }) + +const streamRequestWith = + (runtime: TransportRuntime, compile: ReturnType) => (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + const compiled = yield* compile(request) + return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) + }), + ) + +const makeClient = (runtime: TransportRuntime, transform?: OptionsTransform): Interface => { + const compile = compileWith(transform) + const stream = streamRequestWith(runtime, compile) + return { + prepare: prepareWith(compile) as Interface["prepare"], + stream, + generate: generateWith(stream), + withOptionsTransform: (next) => + makeClient(runtime, transform ? (request) => transform(request).pipe(Effect.flatMap(next)) : next), + } +} const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest) { @@ -423,7 +442,7 @@ const generateWith = (stream: Interface["stream"]) => }) export const prepare = (request: LLMRequest) => - prepareWith(request) as Effect.Effect, LLMError> + prepareWith(compileWith())(request) as Effect.Effect, LLMError> export function stream(request: LLMRequest): Stream.Stream { return Stream.unwrap( @@ -449,11 +468,12 @@ export const streamRequest = (request: LLMRequest) => export const layer: Layer.Layer = 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)), + }), + ) }), ) diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts index 70db881ea4..632c1dae06 100644 --- a/packages/ai/src/route/index.ts +++ b/packages/ai/src/route/index.ts @@ -7,6 +7,7 @@ export type { RouteDefaultsInput, AnyRoute, Interface as LLMClientShape, + OptionsTransform, Service as LLMClientService, } from "./client" export * from "./executor" diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index e2efa1717d..1890c5ce90 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -694,4 +694,47 @@ describe("OpenAI Chat route", () => { expect(events.map((event) => event.type)).toEqual(["step-start"]) }), ) + + it.effect("transforms resolved options before provider lowering", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const resolvedModel = OpenAIChat.route + .with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + generation: { topP: 0.8 }, + }) + .model({ id: "gpt-4o-mini", defaults: { generation: { topK: 4 } } }) + let seen: typeof request.generation + const transformed = LLM.request({ + model: resolvedModel, + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0.1 }, + }) + + yield* llm + .withOptionsTransform((request) => + Effect.sync(() => { + seen = request.generation + const generation = { ...request.generation, temperature: 0.7 } + delete generation.topP + return LLM.updateRequest(request, { generation }) + }), + ) + .stream(transformed) + .pipe(Stream.runDrain) + + expect(seen).toMatchObject({ maxTokens: 20, temperature: 0.1, topP: 0.8, topK: 4 }) + }).pipe( + Effect.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toMatchObject({ max_tokens: 20, temperature: 0.7 }) + expect(JSON.parse(input.text)).not.toHaveProperty("top_p") + return Effect.succeed( + input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }), + ) + }), + ), + ), + ) }) diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 4ac437d392..8d3d73fbd9 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -16,6 +16,10 @@ export interface Domains { type Callback = (event: Event) => Effect.Effect export interface Interface { + readonly has: ( + domain: Domain, + name: Name, + ) => boolean readonly register: ( 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 }) }), ) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 7d7e88e90f..46489a12fd 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -10,6 +10,7 @@ import { SessionContext } from "./context" import { SessionGenerate } from "./generate" import { SessionHistory } from "./history" import { SessionModelHeaders } from "./model-headers" +import { SessionOptionsHook } from "./options-hook" import { SessionRunnerModel } from "./runner/model" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" @@ -46,7 +47,11 @@ const layer = Layer.effect( ], tools: {}, }) - return (yield* llm.generate( + return (yield* SessionOptionsHook.client(llm, hooks, { + sessionID: selection.session.id, + agent: selection.agent.id, + model: model.ref, + }).generate( LLM.request({ model: model.model, http: { headers: SessionModelHeaders.make(selection.session) }, diff --git a/packages/core/src/session/options-hook.ts b/packages/core/src/session/options-hook.ts new file mode 100644 index 0000000000..7807dfb78c --- /dev/null +++ b/packages/core/src/session/options-hook.ts @@ -0,0 +1,26 @@ +export * as SessionOptionsHook from "./options-hook" + +import { LLM } from "@opencode-ai/ai" +import type { LLMClientShape } from "@opencode-ai/ai/route" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import { Effect } from "effect" +import { PluginHooks } from "../plugin/hooks" + +type Identity = Pick + +export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, identity: Identity) => + hooks.has("session", "options") + ? llm.withOptionsTransform((request) => + Effect.gen(function* () { + const event = yield* hooks.trigger("session", "options", { + ...identity, + generation: { ...request.generation }, + providerOptions: structuredClone(request.providerOptions ?? {}), + }) + return LLM.updateRequest(request, { + generation: event.generation, + providerOptions: event.providerOptions, + }) + }), + ) + : llm diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3495cfdc4f..778471c7c6 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -6,6 +6,7 @@ import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream import { Database } from "../../database/database" import { EventV2 } from "../../event" 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" @@ -13,6 +14,7 @@ import { SessionCompaction } from "../compaction" import { SessionContext } from "../context" import { SessionEvent } from "../event" import { SessionPending } from "../pending" +import { SessionOptionsHook } from "../options-hook" import { SessionModelRequest } from "../model-request" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" @@ -40,6 +42,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. @@ -132,7 +135,12 @@ const layer = Layer.effect( const serialized = (effect: Effect.Effect) => 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 sessionLLM = SessionOptionsHook.client(llm, hooks, { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + }) + const providerStream = sessionLLM.stream(prepared.request).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -482,5 +490,6 @@ export const node = makeLocationNode({ SessionTitle.node, Snapshot.node, Database.node, + PluginHooks.node, ], }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 7922d965bb..1da7e4aa2b 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -218,6 +218,38 @@ describe("fromPromise", () => { }), ) + it.effect("forwards session option 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-options", + setup: async (ctx) => { + await ctx.session.hook("options", (event) => { + event.generation.temperature = 0.3 + event.providerOptions.openai ??= {} + event.providerOptions.openai.reasoningEffort = "high" + }) + }, + }), + ).effect(host) + const event: SessionHooks["options"] = { + sessionID: SessionV2.ID.make("ses_promise_session_options"), + agent: AgentV2.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + generation: { temperature: 0.7 }, + providerOptions: {}, + } + + yield* hooks.trigger("session", "options", event) + + expect(event.generation.temperature).toBe(0.3) + expect(event.providerOptions).toEqual({ openai: { reasoningEffort: "high" } }) + }), + ) + it.effect("disposes a hook registration on request", () => Effect.gen(function* () { const agents = yield* AgentV2.Service diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index da90068ea4..06896c3b49 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" +import type { LLMClientShape } from "@opencode-ai/ai/route" 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" @@ -40,14 +41,16 @@ const projects = Layer.succeed( }), ) let requests: LLMRequest[] = [] -const client = Layer.mock(LLMClient.Service)({ +const clientValue: 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"), -}) + withOptionsTransform: () => clientValue, +} +const client = Layer.mock(LLMClient.Service)(clientValue) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const locations = Layer.effect( diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index ad3e0a2bfb..da6056aad2 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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 type { LLMClientShape } from "@opencode-ai/ai/route" 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" @@ -42,7 +43,7 @@ const cost = [ }, }, ] -const client = Layer.mock(LLMClient.Service)({ +const clientValue: LLMClientShape = { prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { requests.push(request) @@ -66,7 +67,9 @@ const client = Layer.mock(LLMClient.Service)({ ) }, generate: () => Effect.die("unused"), -}) + withOptionsTransform: () => clientValue, +} +const client = Layer.mock(LLMClient.Service)(clientValue) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index a66ac6a69e..df83da8f22 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" +import type { LLMClientShape } from "@opencode-ai/ai/route" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -47,7 +48,7 @@ let instruction: string | Instructions.Unavailable = "Initial context" const sessionID = SessionSchema.ID.make("ses_generate_test") const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route }) -const client = Layer.mock(LLMClient.Service)({ +const clientValue: LLMClientShape = { prepare: () => Effect.die(new Error("unused")), stream: () => Stream.die(new Error("unused")), generate: (request) => @@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({ if (!response) throw new Error("Incomplete generate response") return response }), -}) + withOptionsTransform: () => clientValue, +} +const client = Layer.mock(LLMClient.Service)(clientValue) const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const builtins = Layer.mock(InstructionBuiltIns.Service, { load: () => diff --git a/packages/core/test/session-options-hook.test.ts b/packages/core/test/session-options-hook.test.ts new file mode 100644 index 0000000000..157e5e68eb --- /dev/null +++ b/packages/core/test/session-options-hook.test.ts @@ -0,0 +1,57 @@ +import { expect } from "bun:test" +import { LLM, LLMClient, Model as LLMModel } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import type { LLMClientShape, OptionsTransform } 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 { PluginHooks } from "../src/plugin/hooks" +import { SessionOptionsHook } from "../src/session/options-hook" +import { testEffect } from "./lib/effect" + +const layer = PluginHooks.node.implementation as Layer.Layer +const it = testEffect(layer) + +it.effect("applies session option hooks to resolved model options", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const sessionID = Session.ID.make("ses_options") + const agent = Agent.ID.make("build") + const model = Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }) + yield* hooks.register("session", "options", (event) => + Effect.sync(() => { + expect(event).toMatchObject({ sessionID, agent, model }) + event.generation.temperature = 0.2 + delete event.generation.topP + event.providerOptions.openai ??= {} + event.providerOptions.openai.reasoningEffort = "high" + }), + ) + let transform: OptionsTransform | undefined + const llm: LLMClientShape = { + prepare: () => Effect.die("unused"), + stream: () => Stream.die("unused"), + generate: () => Effect.die("unused"), + withOptionsTransform: (next) => { + transform = next + return llm + }, + } + + SessionOptionsHook.client(llm, hooks, { sessionID, agent, model }) + if (!transform) return yield* Effect.die("options transform was not installed") + const result = yield* transform( + LLM.request({ + model: LLMModel.make({ id: "model", provider: "test", route: OpenAIChat.route }), + generation: { temperature: 0.7, topP: 0.9 }, + providerOptions: { openai: { promptCacheKey: "cache" } }, + }), + ) + + expect(result.generation).toMatchObject({ temperature: 0.2 }) + expect(result.generation?.topP).toBeUndefined() + expect(result.providerOptions).toEqual({ openai: { promptCacheKey: "cache", reasoningEffort: "high" } }) + }), +) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 39cf62e847..8abfc2944b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -90,9 +90,7 @@ let toolExecutionsStarted: Deferred.Deferred | 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) @@ -114,8 +112,9 @@ const client = Layer.succeed( ) }) as unknown as LLMClientShape["stream"], generate: () => Effect.die("unused"), - }), -) + withOptionsTransform: () => clientValue, + }) +const client = Layer.succeed(LLMClient.Service, clientValue) const reply = { stop: () => [ LLMEvent.stepStart({ index: 0 }), diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index cf3d7f4ea0..9e101e26d9 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" +import type { LLMClientShape } from "@opencode-ai/ai/route" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -40,7 +41,7 @@ const cost = [ }, }, ] -const client = Layer.mock(LLMClient.Service)({ +const clientValue: LLMClientShape = { prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { requests.push(request) @@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({ ) }, generate: () => Effect.die("unused"), -}) + withOptionsTransform: () => clientValue, +} +const client = Layer.mock(LLMClient.Service)(clientValue) const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), }) diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 8ce7ddb414..20fb28c52f 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -92,6 +92,20 @@ yield * ) ``` +Resolved generation and provider options are mutable before provider lowering: + +```ts +yield * + ctx.session.hook("options", (event) => + Effect.sync(() => { + event.generation.temperature = 0.2 + event.generation.topP = 0.9 + event.providerOptions.openai ??= {} + event.providerOptions.openai.reasoningEffort = "high" + }), + ) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts index 73b87fb3d1..d3b885999e 100644 --- a/packages/plugin/src/v2/effect/session.ts +++ b/packages/plugin/src/v2/effect/session.ts @@ -1,5 +1,5 @@ import type { SessionApi } from "@opencode-ai/client/effect/api" -import type { Message, SystemPart } from "@opencode-ai/ai" +import type { GenerationOptionsFields, Message, SystemPart } 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" @@ -15,8 +15,23 @@ export interface SessionContext { tools: Record } +export type SessionGenerationOptions = { + -readonly [Key in keyof GenerationOptionsFields]: GenerationOptionsFields[Key] +} + +export type SessionProviderOptions = Record> + +export interface SessionOptions { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + generation: SessionGenerationOptions + providerOptions: SessionProviderOptions +} + export interface SessionHooks { readonly context: SessionContext + readonly options: SessionOptions } export type SessionDomain = Pick< diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e23a231e4f..4d4d5bf8e1 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -94,6 +94,17 @@ await ctx.session.hook("context", (event) => { }) ``` +Resolved generation and provider options are mutable before provider lowering: + +```ts +await ctx.session.hook("options", (event) => { + event.generation.temperature = 0.2 + event.generation.topP = 0.9 + event.providerOptions.openai ??= {} + event.providerOptions.openai.reasoningEffort = "high" +}) +``` + Promise tools use plain object declarations with async executors: ```ts diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts index 99346de3ea..ec084d3b57 100644 --- a/packages/plugin/src/v2/promise/session.ts +++ b/packages/plugin/src/v2/promise/session.ts @@ -1,5 +1,5 @@ import type { SessionApi } from "@opencode-ai/client/promise/api" -import type { Message, SystemPart } from "@opencode-ai/ai" +import type { GenerationOptionsFields, Message, SystemPart } 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" @@ -15,8 +15,23 @@ export interface SessionContext { tools: Record } +export type SessionGenerationOptions = { + -readonly [Key in keyof GenerationOptionsFields]: GenerationOptionsFields[Key] +} + +export type SessionProviderOptions = Record> + +export interface SessionOptions { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + generation: SessionGenerationOptions + providerOptions: SessionProviderOptions +} + export interface SessionHooks { readonly context: SessionContext + readonly options: SessionOptions } export type SessionDomain = Pick<