diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index fc701588d57..8b732bfc4c8 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -83,7 +83,24 @@ export namespace LLM { const provider = await Provider.getProvider(input.model.providerID) const small = input.small ? ProviderTransform.smallOptions(input.model) : {} - const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} + + // Allow plugins to override variant selection + const availableVariants = input.model.variants ? Object.keys(input.model.variants) : [] + const variantHook = await Plugin.trigger( + "chat.variant", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + currentVariant: input.user.variant, + availableVariants, + }, + { variant: input.user.variant, options: {} }, + ) + const variantName = variantHook.variant + const variantOptions = input.model.variants && variantName ? input.model.variants[variantName] : {} + const variant = mergeDeep(variantOptions as Record, variantHook.options) + const options = pipe( ProviderTransform.options(input.model, input.sessionID, provider.options), mergeDeep(small), diff --git a/packages/opencode/test/plugin/variant-hook.test.ts b/packages/opencode/test/plugin/variant-hook.test.ts new file mode 100644 index 00000000000..6a2e61da54a --- /dev/null +++ b/packages/opencode/test/plugin/variant-hook.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import type { Hooks } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk" + +describe("chat.variant hook", () => { + test("should accept valid hook definition with variant override", async () => { + // #given + const hook: Hooks["chat.variant"] = async (input, output) => { + output.variant = "high" + } + + const input = { + sessionID: "test-session", + agent: "build", + model: { id: "claude-sonnet-4" } as Model, + currentVariant: undefined, + availableVariants: ["high", "max"], + } + const output = { variant: undefined as string | undefined, options: {} } + + // #when + await hook!(input, output) + + // #then + expect(output.variant).toBe("high") + }) + + test("should accept valid hook definition with options override", async () => { + // #given + const hook: Hooks["chat.variant"] = async (input, output) => { + output.options = { thinking: { budget_tokens: 50000 } } + } + + const input = { + sessionID: "test-session", + agent: "build", + model: { id: "claude-sonnet-4" } as Model, + currentVariant: "high", + availableVariants: ["high", "max"], + } + const output = { variant: "high" as string | undefined, options: {} as Record } + + // #when + await hook!(input, output) + + // #then + expect(output.options).toEqual({ thinking: { budget_tokens: 50000 } }) + }) + + test("should preserve currentVariant when hook does not modify output", async () => { + // #given + const hook: Hooks["chat.variant"] = async (_input, _output) => {} + + const input = { + sessionID: "test-session", + agent: "build", + model: { id: "claude-sonnet-4" } as Model, + currentVariant: "max", + availableVariants: ["high", "max"], + } + const output = { variant: "max" as string | undefined, options: {} } + + // #when + await hook!(input, output) + + // #then + expect(output.variant).toBe("max") + }) + + test("should receive correct input parameters", async () => { + // #given + let receivedInput: Parameters>[0] | undefined + + const hook: Hooks["chat.variant"] = async (input, _output) => { + receivedInput = input + } + + const input = { + sessionID: "ses_123", + agent: "plan", + model: { id: "gpt-4", providerID: "openai" } as Model, + currentVariant: "high", + availableVariants: ["low", "high", "max"], + } + const output = { variant: "high" as string | undefined, options: {} } + + // #when + await hook!(input, output) + + // #then + expect(receivedInput).toBeDefined() + expect(receivedInput!.sessionID).toBe("ses_123") + expect(receivedInput!.agent).toBe("plan") + expect(receivedInput!.model.id).toBe("gpt-4") + expect(receivedInput!.currentVariant).toBe("high") + expect(receivedInput!.availableVariants).toEqual(["low", "high", "max"]) + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 5653f19d912..43ea9bc315e 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -164,6 +164,25 @@ export interface Hooks { input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, output: { temperature: number; topP: number; topK: number; options: Record }, ) => Promise + /** + * Modify the variant used for LLM requests. Allows plugins to override + * thinking/reasoning levels programmatically. + * + * - `variant`: The variant name (e.g., "high", "max") to select from model's available variants + * - `options`: Direct provider options to merge (overrides variant selection) + * + * If both are set, `options` takes precedence over `variant`. + */ + "chat.variant"?: ( + input: { + sessionID: string + agent: string + model: Model + currentVariant: string | undefined + availableVariants: string[] + }, + output: { variant: string | undefined; options: Record }, + ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise "tool.execute.before"?: ( input: { tool: string; sessionID: string; callID: string },