diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 3bbea636145..28b72403561 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -297,17 +297,17 @@ export const layer = Layer.effect( ) // Hooks mutate this record in place: edit descriptions and schemas, rename, or remove. const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])) - const context = - input.contextHooks === false - ? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions } - : yield* hooks.trigger("session", "context", { - sessionID: session.id, - agent: input.scope.agentID, - model: resolved.ref, - system: input.transcript.system, - messages: input.transcript.messages, - tools: definitions, - }) + const context: PluginHooks.Domains["session"]["context"] = { + sessionID: session.id, + agent: input.scope.agentID, + model: resolved.ref, + system: input.transcript.system, + messages: input.transcript.messages, + tools: definitions, + generation: {}, + providerOptions: {}, + } + if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context) // Match each surviving entry back to its tool, by recognizing a moved definition or // by key. Identity wins so a definition moved onto another tool's name still executes // the tool it describes. Entries matching neither were invented by a hook and dropped. @@ -333,6 +333,8 @@ export const layer = Layer.effect( messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), toolChoice: input.toolChoice, + generation: Object.keys(context.generation).length === 0 ? undefined : context.generation, + providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions, }), ) const hasHttpHooks = diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 3b489ecca83..7efcc825329 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -35,6 +35,8 @@ describe("PluginHooks", () => { system: [SystemPart.make("first")], messages: [Message.user("original")], tools: {}, + generation: {}, + providerOptions: {}, } expect(yield* hooks.trigger("session", "context", event)).toBe(event) diff --git a/packages/core/test/plugin/plan.test.ts b/packages/core/test/plugin/plan.test.ts index bae84afa2b2..5c841a76ba3 100644 --- a/packages/core/test/plugin/plan.test.ts +++ b/packages/core/test/plugin/plan.test.ts @@ -124,6 +124,8 @@ const request = (agent: Agent.ID, messages: Array): SessionContext => ( system: [], messages, tools: {}, + generation: {}, + providerOptions: {}, }) type ToolErrorEvent = Extract diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 2f984cafcb9..04ce1ca8d43 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -381,6 +381,8 @@ describe("fromPromise", () => { await ctx.session.hook("context", (event) => { event.system.push(SystemPart.make("Promise hook")) delete event.tools.echo + event.generation.temperature = 0.4 + event.providerOptions.reasoningEffort = "medium" }) }, }), @@ -392,12 +394,16 @@ describe("fromPromise", () => { system: [SystemPart.make("Initial")], messages: [Message.user("Hello")], tools: { echo: { description: "Echo", input: { type: "object" } } }, + generation: {}, + providerOptions: {}, } yield* hooks.trigger("session", "context", event) expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) expect(event.tools).toEqual({}) + expect(event.generation).toEqual({ temperature: 0.4 }) + expect(event.providerOptions).toEqual({ reasoningEffort: "medium" }) }), ) diff --git a/packages/core/test/plugin/system-prompt.test.ts b/packages/core/test/plugin/system-prompt.test.ts index 3d121cee638..5b50c8d8457 100644 --- a/packages/core/test/plugin/system-prompt.test.ts +++ b/packages/core/test/plugin/system-prompt.test.ts @@ -32,6 +32,8 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({ system: [SystemPart.make(system)], messages: [], tools: {}, + generation: {}, + providerOptions: {}, }) describe("SystemPromptPlugin", () => { diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index f87ab1e085a..7ca0852d6e7 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -1,9 +1,165 @@ import { describe, expect, test } from "bun:test" -import { Message, ToolResultPart } from "@opencode-ai/ai" -import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request" +import { LanguageModel, Message, ToolResultPart } from "@opencode-ai/ai" +import { Gemini } from "@opencode-ai/ai/protocols/gemini" +import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses" +import { compileRequest } from "@opencode-ai/ai/route/client" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { SessionModelRequest, boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request" +import { SessionModelTransport } from "@opencode-ai/core/session/model-transport" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Money } from "@opencode-ai/schema/money" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import { Session } from "@opencode-ai/schema/session" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { ConfigProvider, DateTime, Effect } from "effect" +import { testEffect } from "./lib/effect" const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) +const it = testEffect( + LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [ + [SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })], + ]), +) + +const requestInput = (model: LanguageModel) => ({ + scope: { + session: Session.Info.make({ + id: Session.ID.make("ses_request_options"), + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), + }), + agentID: Agent.ID.make("build"), + model: SessionRunnerModel.resolved(model, { + capabilities: { ...capabilities(["text"]), responsesWebsockets: model.provider === "openai" }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + }), + }, + transcript: { system: [], messages: [Message.user("Hello")] }, +}) + +describe("SessionModelRequest.context options", () => { + it.effect("compiles ordered generation and provider overrides without mutating defaults", () => + Effect.gen(function* () { + const requests = yield* SessionModelRequest.Service + const hooks = yield* PluginHooks.Service + const model = Gemini.route + .with({ + generation: { maxTokens: 100, topP: 0.7 }, + providerOptions: { thinkingConfig: { includeThoughts: true, thinkingBudget: 256 } }, + }) + .model({ + id: "gemini-2.5-flash", + defaults: { + generation: { temperature: 0.8 }, + providerOptions: { thinkingConfig: { thinkingBudget: 512 } }, + }, + }) + const baseline = yield* requests.prepare(requestInput(model)) + expect(baseline.request.generation).toBeUndefined() + expect(baseline.request.providerOptions).toBeUndefined() + const first = yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + expect(event.generation).toEqual({}) + expect(event.providerOptions).toEqual({}) + event.generation = { + maxTokens: 2048, + temperature: 0.2, + topK: 40, + frequencyPenalty: 0.1, + presencePenalty: 0.3, + seed: 42, + stop: ["END"], + } + event.providerOptions = { thinkingConfig: { thinkingBudget: 1024 } } + }), + ) + const second = yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + expect(event.generation.temperature).toBe(0.2) + expect(event.providerOptions.thinkingConfig).toEqual({ thinkingBudget: 1024 }) + event.generation.temperature = 0 + event.generation.stop?.push("STOP") + }), + ) + const prepared = yield* requests.prepare(requestInput(model)) + expect((yield* compileRequest(prepared.request)).body).toMatchObject({ + generationConfig: { + maxOutputTokens: 2048, + temperature: 0, + topP: 0.7, + topK: 40, + frequencyPenalty: 0.1, + presencePenalty: 0.3, + seed: 42, + stopSequences: ["END", "STOP"], + thinkingConfig: { includeThoughts: true, thinkingBudget: 1024 }, + }, + }) + // Each new request starts with fresh override objects, even while hooks remain registered. + expect((yield* requests.prepare(requestInput(model))).request.generation).toEqual(prepared.request.generation) + yield* first.dispose + yield* second.dispose + const unhooked = yield* requests.prepare(requestInput(model)) + expect(unhooked.request.generation).toBeUndefined() + expect(unhooked.request.providerOptions).toBeUndefined() + expect((yield* compileRequest(unhooked.request)).body).toEqual((yield* compileRequest(baseline.request)).body) + expect(model.defaults?.generation).toEqual({ temperature: 0.8 }) + expect(model.route.defaults.generation).toEqual({ maxTokens: 100, topP: 0.7 }) + expect(model.defaults?.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 512 } }) + expect(model.route.defaults.providerOptions).toEqual({ + thinkingConfig: { includeThoughts: true, thinkingBudget: 256 }, + }) + }), + ) + + it.effect("compiles OpenAI semantic reasoning options without revoking WebSocket transport", () => + Effect.gen(function* () { + const requests = yield* SessionModelRequest.Service + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", () => Effect.die("Other-provider hook must not run"), { + providerID: "google", + }) + yield* hooks.register( + "session", + "context", + (event) => + Effect.sync(() => { + event.generation.maxTokens = 8000 + event.providerOptions.reasoningEffort = "high" + }), + { providerID: "openai" }, + ) + const input = requestInput(OpenAIResponses.route.model({ id: "gpt-5.5" })) + const prepared = yield* requests.prepare({ ...input, webSocket: "session" }) + expect(prepared.options.webSocket).toBeDefined() + expect(prepared.options.http).toBeUndefined() + expect((yield* compileRequest(prepared.request)).body).toMatchObject({ + max_output_tokens: 8000, + reasoning: { effort: "high" }, + store: false, + include: ["reasoning.encrypted_content"], + }) + const excluded = yield* requests.prepare({ ...input, contextHooks: false }) + expect(excluded.request.generation).toBeUndefined() + expect(excluded.request.providerOptions).toBeUndefined() + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }), + ), + ), + ), + ) +}) + describe("SessionModelRequest.unsupportedParts", () => { test("replaces unsupported user media with a visible error", () => { const messages = unsupportedParts( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 657d5edbb70..bb0d1621429 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1039,6 +1039,11 @@ describe("SessionRunnerLLM", () => { event.messages = [Message.user("Hooked message")] delete event.tools.echo event.tools.unregistered = { description: "Unavailable", input: { type: "object" } } + event.generation.temperature = 0.2 + event.generation.topP = 0.9 + event.generation.topK = 40 + event.generation.maxTokens = 2048 + event.providerOptions.reasoningEffort = "high" }), ) yield* admit(session, "Original message") @@ -1052,6 +1057,8 @@ describe("SessionRunnerLLM", () => { expect(requests[0]?.messages).toEqual([Message.user("Hooked message")]) expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo") expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered") + expect(requests[0]?.generation).toMatchObject({ temperature: 0.2, topP: 0.9, topK: 40, maxTokens: 2048 }) + expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "high" }) expect(executions).toEqual([]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Original message" }, diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 4eb1b5b1fbb..ec3d077b63f 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -1,9 +1,9 @@ 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" -import type { JsonSchema } from "effect" +import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" export interface SessionContext { @@ -13,6 +13,9 @@ export interface SessionContext { system: Array messages: Array tools: Record + /** Request overrides; unset fields retain route and model defaults. */ + generation: Types.DeepMutable + providerOptions: Record } export interface SessionModelRequest { diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index e6565e764ec..325c8393790 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -1,9 +1,9 @@ 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" -import type { JsonSchema } from "effect" +import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" export interface SessionContext { @@ -13,6 +13,9 @@ export interface SessionContext { system: Array messages: Array tools: Record + /** Request overrides; unset fields retain route and model defaults. */ + generation: Types.DeepMutable + providerOptions: Record } export interface SessionModelRequest { diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index f18dcc1df8e..8dc27afedc9 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -1011,21 +1011,54 @@ await registration.dispose() ### Sessions -Modify assembled system instructions, messages, or tools immediately before model dispatch. +Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model +dispatch. ```ts await ctx.session.hook("context", (event) => { - event.system.push("Keep the review focused on correctness.") + event.system.push({ text: "Keep the review focused on correctness." }) delete event.tools.write + event.generation.temperature = 0.2 + event.generation.maxTokens = 8_000 }) ``` +`generation` and `providerOptions` start as empty request-override objects for each context hook invocation, not resolved +model settings. Hooks run in registration order and see earlier hooks' overrides. During compilation, request overrides +take precedence over model defaults, which take precedence over route defaults. Provider option records merge recursively; +arrays and scalar values replace earlier values. Deleting an override or setting it to `undefined` falls back to defaults; +it does not remove a configured default. This precedence applies to semantic generation and provider options; explicit raw +HTTP body overlays are applied after protocol lowering and can override the resulting fields. + +These changes affect only the outgoing model call, not persisted session history or configuration. Context hooks also run +for subsequent calls such as tool-driven continuations, but do not run for title or compaction requests. + +Provider options use the selected protocol's semantic option names, not raw HTTP body fields. Scope provider-specific +settings to the matching provider. For example, OpenAI Responses uses `reasoningEffort`: + +```ts +await ctx.session.hook( + "context", + (event) => { + event.providerOptions.reasoningEffort = "high" + }, + { providerID: "openai" }, +) +``` + +`maxTokens` is the semantic output-token limit. Generation settings are supported only where the selected protocol and +model support them; for example, Gemini supports `topK`, while OpenAI Responses does not expose it. + Modify model request settings and optionally scope the hook to one provider. ```ts -await ctx.session.hook("model.request", (event) => { - event.headers["x-plugin"] = "review" -}, { providerID: "anthropic" }) +await ctx.session.hook( + "model.request", + (event) => { + event.headers["x-plugin"] = "review" + }, + { providerID: "anthropic" }, +) ``` Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading @@ -1054,6 +1087,26 @@ interface SessionHooks { "http.response": SessionHttpResponseHook } +interface SessionContextHook { + readonly sessionID: string + readonly agent: string + readonly model: { providerID: string; id: string; variant?: string } + system: SystemPart[] + messages: Message[] + tools: Record + generation: { + maxTokens?: number + temperature?: number + topP?: number + topK?: number + frequencyPenalty?: number + presencePenalty?: number + seed?: number + stop?: string[] + } + providerOptions: Record +} + interface SessionHookContext { hook( name: Name,