From 9346bc83793f2939bf1929373e6297aa9781c494 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Mon, 16 Mar 2026 07:49:55 +0100 Subject: [PATCH] fix: clamp variant "max" to "high" for non-Opus Claude models on fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent configured with variant: "max" falls back from Opus to Sonnet (or Haiku), the "max" variant was passed through unchanged. OpenCode sends this as level: "max" to the Anthropic API, which rejects it with: level "max" not supported, valid levels: low, medium, high The anthropic-effort hook previously only handled Opus (inject effort=max) and skipped all other Claude models. Now it actively clamps "max" → "high" for non-Opus Claude models and mutates message.variant so OpenCode doesn't pass the unsupported level to the API. --- src/hooks/anthropic-effort/hook.ts | 40 +++++++++++++++++++----- src/hooks/anthropic-effort/index.test.ts | 9 +++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index 16e2656c2..ef6c98529 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -28,6 +28,20 @@ interface ChatParamsOutput { options: Record } +/** + * Valid thinking budget levels per model tier. + * Opus supports "max"; all other Claude models cap at "high". + */ +const MAX_VARIANT_BY_TIER: Record = { + opus: "max", + default: "high", +} + +function clampVariant(variant: string, isOpus: boolean): string { + if (variant !== "max") return variant + return isOpus ? MAX_VARIANT_BY_TIER.opus : MAX_VARIANT_BY_TIER.default +} + export function createAnthropicEffortHook() { return { "chat.params": async ( @@ -38,15 +52,27 @@ export function createAnthropicEffortHook() { if (!model?.modelID || !model?.providerID) return if (message.variant !== "max") return if (!isClaudeProvider(model.providerID, model.modelID)) return - if (!isOpus46(model.modelID)) return if (output.options.effort !== undefined) return - output.options.effort = "max" - log("anthropic-effort: injected effort=max", { - sessionID: input.sessionID, - provider: model.providerID, - model: model.modelID, - }) + const opus = isOpus46(model.modelID) + const clamped = clampVariant(message.variant, opus) + output.options.effort = clamped + + if (!opus) { + // Override the variant so OpenCode doesn't pass "max" to the API + ;(message as { variant?: string }).variant = clamped + log("anthropic-effort: clamped variant max→high for non-Opus model", { + sessionID: input.sessionID, + provider: model.providerID, + model: model.modelID, + }) + } else { + log("anthropic-effort: injected effort=max", { + sessionID: input.sessionID, + provider: model.providerID, + model: model.modelID, + }) + } }, } } diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts index 0ce7e0ccb..666a5c2ac 100644 --- a/src/hooks/anthropic-effort/index.test.ts +++ b/src/hooks/anthropic-effort/index.test.ts @@ -143,8 +143,8 @@ describe("createAnthropicEffortHook", () => { expect(output.options.effort).toBeUndefined() }) - it("should NOT inject effort for non-opus model", async () => { - //#given claude-sonnet-4-6 (not opus) + it("should clamp effort to high for non-opus claude model with variant max", async () => { + //#given claude-sonnet-4-6 (not opus) with variant max const hook = createAnthropicEffortHook() const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6", @@ -153,8 +153,9 @@ describe("createAnthropicEffortHook", () => { //#when chat.params hook is called await hook["chat.params"](input, output) - //#then effort should NOT be injected - expect(output.options.effort).toBeUndefined() + //#then effort should be clamped to high (not max) + expect(output.options.effort).toBe("high") + expect(input.message.variant).toBe("high") }) it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {