feat(config): add model variant support

Allow optional model variant config for agents and categories.
Propagate category variants into task model payloads so
category-driven runs inherit provider-specific variants.

Closes: #647
This commit is contained in:
Jason Kölker
2026-01-10 21:44:20 +00:00
parent f9fce50144
commit 2b8853cbac
18 changed files with 452 additions and 12 deletions

View File

@@ -102,6 +102,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -225,6 +228,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -348,6 +354,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -471,6 +480,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -594,6 +606,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -717,6 +732,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -840,6 +858,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -963,6 +984,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1086,6 +1110,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1209,6 +1236,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1332,6 +1362,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1455,6 +1488,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1578,6 +1614,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1701,6 +1740,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1824,6 +1866,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
@@ -1954,6 +1999,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,

View File

@@ -76,6 +76,7 @@ export type AgentName = BuiltinAgentName
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string
variant?: string
}
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>

View File

@@ -127,6 +127,31 @@ describe("buildAgent with category and skills", () => {
expect(agent.temperature).toBe(0.7)
})
test("agent with category inherits variant", () => {
// #given
const source = {
"test-agent": () =>
({
description: "Test agent",
category: "custom-category",
}) as AgentConfig,
}
const categories = {
"custom-category": {
model: "openai/gpt-5.2",
variant: "xhigh",
},
}
// #when
const agent = buildAgent(source["test-agent"], undefined, categories)
// #then
expect(agent.model).toBe("openai/gpt-5.2")
expect(agent.variant).toBe("xhigh")
})
test("agent with skills has content prepended to prompt", () => {
// #given
const source = {

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, CategoryConfig } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
@@ -47,12 +48,19 @@ function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
export function buildAgent(source: AgentSource, model?: string): AgentConfig {
export function buildAgent(
source: AgentSource,
model?: string,
categories?: CategoriesConfig
): AgentConfig {
const base = isFactory(source) ? source(model) : source
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[] }
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {
const categoryConfig = DEFAULT_CATEGORIES[agentWithCategory.category]
const categoryConfig = categoryConfigs[agentWithCategory.category]
if (categoryConfig) {
if (!base.model) {
base.model = categoryConfig.model
@@ -60,6 +68,9 @@ export function buildAgent(source: AgentSource, model?: string): AgentConfig {
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
base.temperature = categoryConfig.temperature
}
if (base.variant === undefined && categoryConfig.variant !== undefined) {
base.variant = categoryConfig.variant
}
}
}
@@ -118,11 +129,16 @@ export function createBuiltinAgents(
disabledAgents: BuiltinAgentName[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string
systemDefaultModel?: string,
categories?: CategoriesConfig
): Record<string, AgentConfig> {
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
@@ -133,7 +149,7 @@ export function createBuiltinAgents(
const override = agentOverrides[agentName]
const model = override?.model
let config = buildAgent(source, model)
let config = buildAgent(source, model, mergedCategories)
if (agentName === "librarian" && directory && config.prompt) {
const envContext = createEnvContext()

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, OhMyOpenCodeConfigSchema } from "./schema"
import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, CategoryConfigSchema, OhMyOpenCodeConfigSchema } from "./schema"
describe("disabled_mcps schema", () => {
test("should accept built-in MCP names", () => {
@@ -174,6 +174,33 @@ describe("AgentOverrideConfigSchema", () => {
})
})
describe("variant field", () => {
test("accepts variant as optional string", () => {
// #given
const config = { variant: "high" }
// #when
const result = AgentOverrideConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.variant).toBe("high")
}
})
test("rejects non-string variant", () => {
// #given
const config = { variant: 123 }
// #when
const result = AgentOverrideConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(false)
})
})
describe("skills field", () => {
test("accepts skills as optional string array", () => {
// #given
@@ -303,6 +330,33 @@ describe("AgentOverrideConfigSchema", () => {
})
})
describe("CategoryConfigSchema", () => {
test("accepts variant as optional string", () => {
// #given
const config = { model: "openai/gpt-5.2", variant: "xhigh" }
// #when
const result = CategoryConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.variant).toBe("xhigh")
}
})
test("rejects non-string variant", () => {
// #given
const config = { model: "openai/gpt-5.2", variant: 123 }
// #when
const result = CategoryConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(false)
})
})
describe("BuiltinCategoryNameSchema", () => {
test("accepts all builtin category names", () => {
// #given

View File

@@ -97,6 +97,7 @@ export const BuiltinCommandNameSchema = z.enum([
export const AgentOverrideConfigSchema = z.object({
/** @deprecated Use `category` instead. Model is inherited from category defaults. */
model: z.string().optional(),
variant: z.string().optional(),
/** Category name to inherit model and other settings from CategoryConfig */
category: z.string().optional(),
/** Skill names to inject into agent prompt */
@@ -153,6 +154,7 @@ export const SisyphusAgentConfigSchema = z.object({
export const CategoryConfigSchema = z.object({
model: z.string(),
variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
maxTokens: z.number().optional(),

View File

@@ -27,7 +27,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
/** Agent name used for concurrency tracking */
concurrencyKey?: string
/** Parent session's agent name for notification */
@@ -46,7 +46,7 @@ export interface LaunchInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
model?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
skills?: string[]
skillContent?: string
}

View File

@@ -210,4 +210,26 @@ describe("keyword-detector session filtering", () => {
expect(output.message.variant).toBe("max")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should not override existing variant", async () => {
// #given - main session set with pre-existing variant
setMainSession("main-123")
const toastCalls: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
const output = {
message: { variant: "low" } as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork mode" }],
}
// #when - ultrawork keyword triggers
await hook["chat.message"](
{ sessionID: "main-123" },
output
)
// #then - existing variant should remain
expect(output.message.variant).toBe("low")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
})

View File

@@ -47,7 +47,9 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
if (hasUltrawork) {
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
if (output.message.variant === undefined) {
output.message.variant = "max"
}
ctx.client.tui
.showToast({

View File

@@ -37,6 +37,8 @@ import {
createContextInjectorMessagesTransformHook,
} from "./features/context-injector";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import { applyAgentVariant, resolveAgentVariant } from "./shared/agent-variant";
import { createFirstMessageVariantGate } from "./shared/first-message-variant";
import {
discoverUserClaudeSkills,
discoverProjectClaudeSkills,
@@ -82,6 +84,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const firstMessageVariantGate = createFirstMessageVariantGate();
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
const modelCacheState = createModelCacheState();
@@ -316,6 +319,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"chat.message": async (input, output) => {
const message = (output as { message: { variant?: string } }).message
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
const variant = resolveAgentVariant(pluginConfig, input.agent)
if (variant !== undefined) {
message.variant = variant
}
firstMessageVariantGate.markApplied(input.sessionID)
} else {
applyAgentVariant(pluginConfig, input.agent, message)
}
await keywordDetector?.["chat.message"]?.(input, output);
await claudeCodeHooks["chat.message"]?.(input, output);
await contextInjector["chat.message"]?.(input, output);
@@ -422,6 +436,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id);
}
firstMessageVariantGate.markSessionCreated(sessionInfo);
}
if (event.type === "session.deleted") {
@@ -431,6 +446,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
await skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients();
}

View File

@@ -103,7 +103,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
pluginConfig.disabled_agents,
pluginConfig.agents,
ctx.directory,
config.model as string | undefined
config.model as string | undefined,
pluginConfig.categories
);
// Claude Code agents: Do NOT apply permission migration

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../config"
import { applyAgentVariant, resolveAgentVariant } from "./agent-variant"
describe("resolveAgentVariant", () => {
test("returns undefined when agent name missing", () => {
// #given
const config = {} as OhMyOpenCodeConfig
// #when
const variant = resolveAgentVariant(config)
// #then
expect(variant).toBeUndefined()
})
test("returns agent override variant", () => {
// #given
const config = {
agents: {
Sisyphus: { variant: "low" },
},
} as OhMyOpenCodeConfig
// #when
const variant = resolveAgentVariant(config, "Sisyphus")
// #then
expect(variant).toBe("low")
})
test("returns category variant when agent uses category", () => {
// #given
const config = {
agents: {
Sisyphus: { category: "ultrabrain" },
},
categories: {
ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" },
},
} as OhMyOpenCodeConfig
// #when
const variant = resolveAgentVariant(config, "Sisyphus")
// #then
expect(variant).toBe("xhigh")
})
})
describe("applyAgentVariant", () => {
test("sets variant when message is undefined", () => {
// #given
const config = {
agents: {
Sisyphus: { variant: "low" },
},
} as OhMyOpenCodeConfig
const message: { variant?: string } = {}
// #when
applyAgentVariant(config, "Sisyphus", message)
// #then
expect(message.variant).toBe("low")
})
test("does not override existing variant", () => {
// #given
const config = {
agents: {
Sisyphus: { variant: "low" },
},
} as OhMyOpenCodeConfig
const message = { variant: "max" }
// #when
applyAgentVariant(config, "Sisyphus", message)
// #then
expect(message.variant).toBe("max")
})
})

View File

@@ -0,0 +1,40 @@
import type { OhMyOpenCodeConfig } from "../config"
export function resolveAgentVariant(
config: OhMyOpenCodeConfig,
agentName?: string
): string | undefined {
if (!agentName) {
return undefined
}
const agentOverrides = config.agents as
| Record<string, { variant?: string; category?: string }>
| undefined
const agentOverride = agentOverrides?.[agentName]
if (!agentOverride) {
return undefined
}
if (agentOverride.variant) {
return agentOverride.variant
}
const categoryName = agentOverride.category
if (!categoryName) {
return undefined
}
return config.categories?.[categoryName]?.variant
}
export function applyAgentVariant(
config: OhMyOpenCodeConfig,
agentName: string | undefined,
message: { variant?: string }
): void {
const variant = resolveAgentVariant(config, agentName)
if (variant !== undefined && message.variant === undefined) {
message.variant = variant
}
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { createFirstMessageVariantGate } from "./first-message-variant"
describe("createFirstMessageVariantGate", () => {
test("marks new sessions and clears after apply", () => {
// #given
const gate = createFirstMessageVariantGate()
// #when
gate.markSessionCreated({ id: "session-1" })
// #then
expect(gate.shouldOverride("session-1")).toBe(true)
// #when
gate.markApplied("session-1")
// #then
expect(gate.shouldOverride("session-1")).toBe(false)
})
test("ignores forked sessions", () => {
// #given
const gate = createFirstMessageVariantGate()
// #when
gate.markSessionCreated({ id: "session-2", parentID: "session-parent" })
// #then
expect(gate.shouldOverride("session-2")).toBe(false)
})
})

View File

@@ -0,0 +1,28 @@
type SessionInfo = {
id?: string
parentID?: string
}
export function createFirstMessageVariantGate() {
const pending = new Set<string>()
return {
markSessionCreated(info?: SessionInfo) {
if (info?.id && !info.parentID) {
pending.add(info.id)
}
},
shouldOverride(sessionID?: string) {
if (!sessionID) return false
return pending.has(sessionID)
},
markApplied(sessionID?: string) {
if (!sessionID) return
pending.delete(sessionID)
},
clear(sessionID?: string) {
if (!sessionID) return
pending.delete(sessionID)
},
}
}

View File

@@ -21,3 +21,4 @@ export * from "./opencode-version"
export * from "./permission-compat"
export * from "./external-plugin-detector"
export * from "./zip-extractor"
export * from "./agent-variant"

View File

@@ -207,6 +207,70 @@ describe("sisyphus-task", () => {
})
})
describe("category variant", () => {
test("passes variant to background model payload", async () => {
// #given
const { createSisyphusTask } = require("./tools")
let launchInput: any
const mockManager = {
launch: async (input: any) => {
launchInput = input
return {
id: "task-variant",
sessionID: "session-variant",
description: "Variant task",
agent: "Sisyphus-Junior",
status: "running",
}
},
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createSisyphusTask({
manager: mockManager,
client: mockClient,
userCategories: {
ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" },
},
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "Sisyphus",
abort: new AbortController().signal,
}
// #when
await tool.execute(
{
description: "Variant task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: true,
skills: [],
},
toolContext
)
// #then
expect(launchInput.model).toEqual({
providerID: "openai",
modelID: "gpt-5.2",
variant: "xhigh",
})
})
})
describe("skills parameter", () => {
test("SISYPHUS_TASK_DESCRIPTION documents skills parameter", () => {
// #given / #when / #then

View File

@@ -315,7 +315,7 @@ ${textContent || "(No text output)"}`
}
let agentToUse: string
let categoryModel: { providerID: string; modelID: string } | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let categoryPromptAppend: string | undefined
if (args.category) {
@@ -325,7 +325,12 @@ ${textContent || "(No text output)"}`
}
agentToUse = SISYPHUS_JUNIOR_AGENT
categoryModel = parseModelString(resolved.config.model)
const parsedModel = parseModelString(resolved.config.model)
categoryModel = parsedModel
? (resolved.config.variant
? { ...parsedModel, variant: resolved.config.variant }
: parsedModel)
: undefined
categoryPromptAppend = resolved.promptAppend || undefined
} else {
agentToUse = args.subagent_type!.trim()