From 9b10eede6e14fe1ca2d5567ff353106dbbe44cc9 Mon Sep 17 00:00:00 2001 From: agnusdei1207 Date: Mon, 6 Jul 2026 17:07:30 +0900 Subject: [PATCH] feat(plugin): add model request hook --- packages/opencode/src/session/llm/request.ts | 30 ++++++- .../opencode/test/session/llm-request.test.ts | 89 +++++++++++++++++++ packages/plugin/src/index.ts | 19 ++++ packages/web/src/content/docs/plugins.mdx | 19 ++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/session/llm-request.test.ts diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107df..1b5125a1d308 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -178,7 +178,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ? (yield* InstanceState.context).project.id : undefined - return { + const request = { system, messages, tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), @@ -203,6 +203,34 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ...headers, }, } + + const transformed = yield* input.plugin.trigger( + "model.request.before", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + provider: input.provider, + message: input.user, + }, + { + system: request.system, + messages: request.messages, + tools: request.tools, + params: request.params, + headers: request.headers, + }, + ) + + return { + ...request, + system: transformed.system, + messages: transformed.messages as Prepared["messages"], + tools: transformed.tools as Prepared["tools"], + params: transformed.params, + messageTransformOptions: transformed.params.options, + headers: transformed.headers, + } }) function resolveTools(input: Pick) { diff --git a/packages/opencode/test/session/llm-request.test.ts b/packages/opencode/test/session/llm-request.test.ts new file mode 100644 index 000000000000..eabfb31ce801 --- /dev/null +++ b/packages/opencode/test/session/llm-request.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { jsonSchema } from "ai" +import { LLMRequestPrep } from "@/session/llm/request" + +const sessionID = "test-session-123" + +const model = { + id: "openai/gpt-4", + providerID: "openai", + api: { + id: "gpt-4", + npm: "@ai-sdk/openai", + }, + capabilities: { + temperature: true, + reasoning: false, + }, + limit: { output: 4096 }, + options: {}, + headers: {}, +} as any + +type RequestHookOutput = { + system: string[] + messages: Array<{ role: string; content: string }> + headers: Record + params: { + options: Record + } +} + +describe("LLMRequestPrep.prepare", () => { + test("runs model request hook before provider execution", async () => { + const result = await Effect.runPromise( + LLMRequestPrep.prepare({ + user: { + id: "msg_user-test", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "openai", modelID: "gpt-4" }, + } as any, + sessionID, + model, + agent: { + name: "test", + prompt: "system", + mode: "primary", + options: {}, + permission: [], + } as any, + system: [], + messages: [{ role: "user", content: "secret@example.com" }], + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, + provider: { id: "openai", options: {} } as any, + auth: undefined, + plugin: { + trigger: (name: string, _input: unknown, output: unknown) => { + if (name !== "model.request.before") return Effect.succeed(output) + const request = output as RequestHookOutput + request.system = ["redacted system"] + request.messages = [{ role: "user", content: "[redacted]" }] + request.headers["x-redacted"] = "1" + request.params.options.redacted = true + return Effect.succeed(output) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + }), + ) + + expect(result.system).toEqual(["redacted system"]) + expect(result.messages).toEqual([{ role: "user", content: "[redacted]" }]) + expect((result.headers as Record)["x-redacted"]).toBe("1") + expect(result.params.options.redacted).toBe(true) + expect(result.messageTransformOptions.redacted).toBe(true) + expect(result.tools.lookup.strict).toBe(false) + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..8f0d039ae4b0 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -258,6 +258,25 @@ export interface Hooks { input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, output: { headers: Record }, ) => Promise + /** + * Modify the model request before it is sent to the provider + */ + "model.request.before"?: ( + input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, + output: { + system: string[] + messages: unknown[] + tools: Record + params: { + temperature: number | undefined + topP: number | undefined + topK: number | undefined + maxOutputTokens: number | undefined + options: Record + } + headers: Record + }, + ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise "command.execute.before"?: ( input: { command: string; sessionID: string; arguments: string }, diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index a8be798217a8..29f91e2c1a3e 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -335,6 +335,25 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco --- +### Model request + +Use `model.request.before` to modify the provider-bound request after OpenCode resolves messages, tools, parameters, and headers. +Use `tool.execute.before` and `tool.execute.after` for tool arguments and tool results. + +```ts title=".opencode/plugins/redact.ts" +export const RedactPlugin = async () => ({ + "model.request.before": async (_input, output) => { + output.messages = output.messages.map((message) => { + if (typeof message !== "object" || !message || !("content" in message)) return message + if (typeof message.content !== "string") return message + return { ...message, content: message.content.replaceAll("secret@example.com", "[redacted]") } + }) + }, +}) +``` + +--- + ### Compaction hooks Customize the context included when a session is compacted: