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
+83
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")
})
})
+40
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
}
}
+32
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)
})
})
+28
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)
},
}
}
+1
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"