diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 867ef2ca2fa8..c7f80b296590 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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, @@ -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 @@ -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) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f0be2d153751..7a7263fb8b3b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -774,6 +774,20 @@ function googleThinkingVariants(model: Provider.Model): Record>): Record> { + // 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> { if (!model.capabilities.reasoning) return {} diff --git a/packages/opencode/src/session/auto-reasoning.ts b/packages/opencode/src/session/auto-reasoning.ts new file mode 100644 index 000000000000..f7272063c78b --- /dev/null +++ b/packages/opencode/src/session/auto-reasoning.ts @@ -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 = { + none: 0, + minimal: 1, + low: 2, + medium: 3, + high: 4, + xhigh: 5, + max: 6, +} + +const TARGET: Record = { 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, 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> | undefined }, + effort: Effort, +) { + if (!model.variants) return {} + const selected = selectVariant(model.variants, effort) + return selected ? model.variants[selected] : {} +} + +export function autoVariant( + model: { variants?: Record> | undefined }, + messages: ModelMessage[], +) { + const prompt = promptText(messages) + return variantOptions(model, classify(prompt.text, prompt.attachments)) +} + +export * as AutoReasoning from "./auto-reasoning" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index e000d6ca49b5..554720946d55 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -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" @@ -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({ @@ -205,6 +203,12 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre } }) +function resolveVariant(input: Pick) { + 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) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..3f12b61e0baa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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" @@ -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(), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b10..eb4eef9928c2 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -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" } }, }) @@ -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: {}, }) }) diff --git a/packages/opencode/test/session/auto-reasoning.test.ts b/packages/opencode/test/session/auto-reasoning.test.ts new file mode 100644 index 000000000000..4869d55744e2 --- /dev/null +++ b/packages/opencode/test/session/auto-reasoning.test.ts @@ -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") + }) +})