Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>, variantHook.options)

const options = pipe(
ProviderTransform.options(input.model, input.sessionID, provider.options),
mergeDeep(small),
Expand Down
98 changes: 98 additions & 0 deletions packages/opencode/test/plugin/variant-hook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> }

// #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<NonNullable<Hooks["chat.variant"]>>[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"])
})
})
19 changes: 19 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> },
) => Promise<void>
/**
* 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<string, any> },
) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
Expand Down