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
9 changes: 7 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,7 +1311,9 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
variants: {},
}

const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base)
const variants = ProviderTransform.withAutoVariant(
ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base),
)

return {
...base,
Expand Down Expand Up @@ -1571,7 +1573,7 @@ const layer = Layer.effect(
: ProviderTransform.variants(parsedModel)
const merged = mergeDeep(variants, model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
ProviderTransform.withAutoVariant(pickBy(merged, (v) => !v.disabled)),
(v) => omit(v, ["disabled"]),
)
parsed.models[modelID] = parsedModel
Expand Down Expand Up @@ -1710,6 +1712,9 @@ const layer = Layer.effect(
(v) => omit(v, ["disabled"]),
)
}
if (model.variants) {
model.variants = mapValues(ProviderTransform.withAutoVariant(model.variants), (v) => v)
}
}

if (Object.keys(provider.models).length === 0) {
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,20 @@ function googleThinkingVariants(model: Provider.Model): Record<string, Record<st
)
}

// Variant that asks opencode to classify the current turn and pick an effort
// variant per request instead of honoring a fixed reasoning setting. Applied
// where the model is assembled so the generated effort map stays provider-pure.
export const AUTO_VARIANT = "auto"

export function withAutoVariant(variants: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
// Auto only makes sense when there is a real choice of efforts to pick from.
// Any stale auto is dropped when config disabled enough concrete variants.
// Appended last so existing "first variant" defaults stay unchanged.
const concrete = Object.fromEntries(Object.entries(variants).filter(([key]) => key !== AUTO_VARIANT))
if (Object.keys(concrete).length < 2) return concrete
return { ...concrete, [AUTO_VARIANT]: {} }
}

export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}

Expand Down
122 changes: 122 additions & 0 deletions packages/opencode/src/session/auto-reasoning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { ModelMessage } from "ai"
import { AUTO_VARIANT } from "@/provider/transform"

export type Effort = "low" | "medium" | "high" | "xhigh"

export const VARIANT = AUTO_VARIANT

const OVERRIDE = /^\s*\[(?:think|reasoning)\s*:\s*(low|medium|high|xhigh)\]/i

const COMPLEX_TERMS = [
/\b(?:architect|debug|diagnos|implement|migrat|optim|refactor|security|concurren|race condition|root cause|proof|prove|theorem|derive|benchmark|regression|plugin|integration)\w*/gi,
/(?:架构|调试|诊断|实现|迁移|优化|重构|安全|并发|竞态|根因|证明|定理|推导|基准|回归|插件|集成)/g,
]

const TASK_TERMS = [
/\b(?:investigat|research|compar|review|updat|creat|build|fix|test|verif|analy|design|automatic)\w*/gi,
/(?:调查|研究|比较|审查|检查|更新|创建|修复|测试|验证|分析|设计|尝试|功能|自动)/g,
]

const VERY_COMPLEX_TERMS = [
/\b(?:formal proof|distributed system|cryptograph|deadlock|production incident|data loss|breaking change|cross-platform|end-to-end)\w*/gi,
/(?:形式化证明|分布式系统|密码学|死锁|生产事故|数据丢失|破坏性变更|跨平台|端到端)/g,
]

const SIMPLE_TERMS = [
/^\s*(?:what time|what date|translate|rename|format|summarize|explain briefly)\b/i,
/^\s*(?:几点|日期|翻译|重命名|格式化|简要总结|简单解释)/,
]

const RANK: Record<string, number> = {
none: 0,
minimal: 1,
low: 2,
medium: 3,
high: 4,
xhigh: 5,
max: 6,
}

const TARGET: Record<Effort, number> = { low: 2, medium: 3, high: 4, xhigh: 5 }

function matches(text: string, patterns: RegExp[]) {
return patterns.reduce((total, pattern) => total + (text.match(pattern)?.length ?? 0), 0)
}

function isEffort(value: string | undefined): value is Effort {
return value === "low" || value === "medium" || value === "high" || value === "xhigh"
}

export function classify(text: string, attachmentCount: number): Effort {
const override = text.match(OVERRIDE)?.[1]?.toLowerCase()
if (isEffort(override)) return override

let score = 1
const length = text.length
if (length > 400) score += 1
if (length > 1_200) score += 2
if (length > 3_000) score += 2

const codeBlocks = text.match(/```/g)?.length ?? 0
score += Math.min(2, Math.floor(codeBlocks / 2))
score += Math.min(3, attachmentCount * 2)
score += Math.min(5, matches(text, COMPLEX_TERMS))
score += Math.min(4, matches(text, TASK_TERMS) * 2)
score += Math.min(4, matches(text, VERY_COMPLEX_TERMS) * 2)

const requirementLines = text.match(/^\s*(?:[-*]|\d+[.)])\s+.+$/gm)?.length ?? 0
if (requirementLines >= 2) score += 1
if (requirementLines >= 5) score += 2

if (length < 180 && SIMPLE_TERMS.some((pattern) => pattern.test(text))) score -= 2

if (score >= 10) return "xhigh"
if (score >= 6) return "high"
if (score >= 3) return "medium"
return "low"
}

export function promptText(messages: ModelMessage[]): { text: string; attachments: number } {
const last = messages.findLast((message) => message.role === "user")
if (!last) return { text: "", attachments: 0 }
if (typeof last.content === "string") return { text: last.content, attachments: 0 }

let text = ""
let attachments = 0
for (const part of last.content) {
if (part.type === "text") text += part.text
else if (part.type === "file" || part.type === "image") attachments++
}
return { text, attachments }
}

// Pick the available variant whose effort rank is closest to the classified
// effort, so models that only expose a subset (e.g. low/high) still resolve.
export function selectVariant(variants: Record<string, unknown>, effort: Effort): string | undefined {
const candidates = Object.keys(variants)
.filter((key) => key !== VARIANT && key !== "default" && RANK[key] !== undefined)
.toSorted((a, b) => RANK[a] - RANK[b])
if (candidates.length === 0) return undefined

const target = TARGET[effort]
return candidates.reduce((best, key) => (Math.abs(RANK[key] - target) < Math.abs(RANK[best] - target) ? key : best))
}

export function variantOptions(
model: { variants?: Record<string, Record<string, unknown>> | undefined },
effort: Effort,
) {
if (!model.variants) return {}
const selected = selectVariant(model.variants, effort)
return selected ? model.variants[selected] : {}
}

export function autoVariant(
model: { variants?: Record<string, Record<string, unknown>> | undefined },
messages: ModelMessage[],
) {
const prompt = promptText(messages)
return variantOptions(model, classify(prompt.text, prompt.attachments))
}

export * as AutoReasoning from "./auto-reasoning"
12 changes: 8 additions & 4 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { MessageV2 } from "../message-v2"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "../system"
import { AutoReasoning } from "../auto-reasoning"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Record } from "effect"
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
Expand Down Expand Up @@ -77,10 +78,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
system.push(header, rest.join("\n"))
}

const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const variant = resolveVariant(input)
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
Expand Down Expand Up @@ -205,6 +203,12 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
}
})

function resolveVariant(input: Pick<PrepareInput, "small" | "model" | "user" | "messages">) {
if (input.small || !input.model.variants || !input.user.model.variant) return {}
if (input.user.model.variant === AutoReasoning.VARIANT) return AutoReasoning.autoVariant(input.model, input.messages)
return input.model.variants[input.user.model.variant]
}

function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { pathToFileURL, fileURLToPath } from "url"
import { Config } from "@/config/config"
import { ConfigMarkdown } from "@/config/markdown"
import { SessionSummary } from "./summary"
import { AutoReasoning } from "./auto-reasoning"
import { NamedError } from "@opencode-ai/core/util/error"
import { SessionProcessor } from "./processor"
import { Tool } from "@/tool/tool"
Expand Down Expand Up @@ -651,7 +652,9 @@ const layer = Layer.effect(
.getModel(model.providerID, model.modelID)
.pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
: undefined
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
const variant =
input.variant ??
(ag.variant && (ag.variant === AutoReasoning.VARIANT || full?.variants?.[ag.variant]) ? ag.variant : undefined)

const info: SessionV1.User = {
id: input.messageID ?? MessageID.ascending(),
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1540,7 +1540,7 @@ test("models.dev reasoning options replace generated variants and unsupported to
},
})
expect(models.empty.variants).toEqual({})
expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"])
expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh", "auto"])
expect(models.override.variants).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
})
Expand Down Expand Up @@ -1572,6 +1572,7 @@ test("MERGE Gateway exposes declared effort variants without model-specific hand
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
max: { reasoningEffort: "max" },
auto: {},
})
})

Expand Down
135 changes: 135 additions & 0 deletions packages/opencode/test/session/auto-reasoning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import type { ModelMessage } from "ai"
import { AutoReasoning } from "@/session/auto-reasoning"
import { LLMRequestPrep } from "@/session/llm/request"

describe("AutoReasoning.classify", () => {
test("short simple prompts resolve to low", () => {
expect(AutoReasoning.classify("what time is it", 0)).toBe("low")
})

test("implementation work resolves above low", () => {
expect(AutoReasoning.classify("implement a migration to the new auth architecture", 0)).toBe("medium")
expect(
AutoReasoning.classify("implement a migration and debug the race condition in the auth architecture", 0),
).toBe("high")
})

test("explicit override wins", () => {
expect(AutoReasoning.classify("[think:xhigh] rename this file", 0)).toBe("xhigh")
expect(AutoReasoning.classify("[reasoning:low] refactor the entire auth system", 0)).toBe("low")
})

test("attachments raise the effort", () => {
expect(AutoReasoning.classify("look at this", 2)).not.toBe("low")
})
})

describe("AutoReasoning.selectVariant", () => {
test("picks the closest available effort", () => {
const variants = { low: {}, medium: {}, high: {} }
expect(AutoReasoning.selectVariant(variants, "low")).toBe("low")
expect(AutoReasoning.selectVariant(variants, "high")).toBe("high")
})

test("clamps when the exact effort is missing", () => {
const variants = { low: {}, high: {} }
expect(AutoReasoning.selectVariant(variants, "medium")).toBe("low")
expect(AutoReasoning.selectVariant(variants, "xhigh")).toBe("high")
})

test("ignores the auto sentinel and default", () => {
const variants = { auto: {}, default: {}, low: {}, max: {} }
expect(AutoReasoning.selectVariant(variants, "xhigh")).toBe("max")
})

test("returns undefined when no effort variants exist", () => {
expect(AutoReasoning.selectVariant({ auto: {} }, "high")).toBeUndefined()
})
})

describe("AutoReasoning.promptText", () => {
test("reads the last user message", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "first" },
{ role: "assistant", content: "reply" },
{ role: "user", content: "second" },
]
expect(AutoReasoning.promptText(messages).text).toBe("second")
})

test("joins text parts and counts attachments", () => {
const messages: ModelMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "describe " },
{ type: "text", text: "this" },
{ type: "file", data: "data:image/png;base64,AAAA", mediaType: "image/png" },
],
},
]
expect(AutoReasoning.promptText(messages)).toEqual({ text: "describe this", attachments: 1 })
})
})

describe("AutoReasoning in LLMRequestPrep", () => {
const model = {
id: "openai/gpt-5",
providerID: "openai",
api: { id: "gpt-5", url: "https://api.openai.com/v1", npm: "@ai-sdk/openai" },
name: "GPT-5",
capabilities: { temperature: false, reasoning: true, attachment: true, toolcall: true },
options: {},
limit: { context: 400_000, output: 128_000 },
variants: {
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
},
}

function prepare(content: string, variant: string = AutoReasoning.VARIANT) {
return Effect.runPromise(
LLMRequestPrep.prepare({
user: {
id: "msg_user",
sessionID: "session",
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: "openai", modelID: "gpt-5", variant },
} as any,
sessionID: "session",
model: model as any,
agent: { name: "test", mode: "primary", options: {}, permission: [] } as any,
system: [],
messages: [{ role: "user", content }],
tools: {},
provider: { id: "openai", options: {} } as any,
auth: undefined,
plugin: {
trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output),
list: () => Effect.succeed([]),
init: () => Effect.void,
} as any,
flags: { outputTokenMax: 32_000, client: "test" } as any,
isWorkflow: false,
}),
)
}

test("simple prompt selects the low effort variant", async () => {
expect((await prepare("what time is it")).params.options.reasoningEffort).toBe("low")
})

test("complex prompt selects the high effort variant", async () => {
const text = "implement a migration of the auth architecture and debug the race condition"
expect((await prepare(text)).params.options.reasoningEffort).toBe("high")
})

test("a literal variant still wins when configured", async () => {
expect((await prepare("what time is it", "high")).params.options.reasoningEffort).toBe("high")
})
})
Loading