From 9346bc83793f2939bf1929373e6297aa9781c494 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Mon, 16 Mar 2026 07:49:55 +0100 Subject: [PATCH 1/2] 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 () => { From 71b1f7e80713a6c8ac4ee829c60eb9c9b9f3c382 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 17 Mar 2026 11:57:56 +0100 Subject: [PATCH 2/2] fix(anthropic-effort): clamp variant against mutable request message --- src/hooks/anthropic-effort/hook.ts | 8 +++--- src/hooks/anthropic-effort/index.test.ts | 15 +++++++++++ src/plugin/chat-params.test.ts | 33 ++++++++++++++++++++++++ src/plugin/chat-params.ts | 12 ++++++--- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index ef6c98529..47c4d7c87 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -1,6 +1,6 @@ import { log, normalizeModelID } from "../../shared" -const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i +const OPUS_PATTERN = /claude-opus/i function isClaudeProvider(providerID: string, modelID: string): boolean { if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true @@ -8,9 +8,9 @@ function isClaudeProvider(providerID: string, modelID: string): boolean { return false } -function isOpus46(modelID: string): boolean { +function isOpusModel(modelID: string): boolean { const normalized = normalizeModelID(modelID) - return OPUS_4_6_PATTERN.test(normalized) + return OPUS_PATTERN.test(normalized) } interface ChatParamsInput { @@ -54,7 +54,7 @@ export function createAnthropicEffortHook() { if (!isClaudeProvider(model.providerID, model.modelID)) return if (output.options.effort !== undefined) return - const opus = isOpus46(model.modelID) + const opus = isOpusModel(model.modelID) const clamped = clampVariant(message.variant, opus) output.options.effort = clamped diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts index 666a5c2ac..a82665cdc 100644 --- a/src/hooks/anthropic-effort/index.test.ts +++ b/src/hooks/anthropic-effort/index.test.ts @@ -116,6 +116,21 @@ describe("createAnthropicEffortHook", () => { //#then should normalize and inject effort expect(output.options.effort).toBe("max") }) + + it("should preserve max for other opus model IDs such as opus-4-5", async () => { + //#given another opus model id that is not 4.6 + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + modelID: "claude-opus-4-5", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then max should still be treated as valid for opus family + expect(output.options.effort).toBe("max") + expect(input.message.variant).toBe("max") + }) }) describe("conditions NOT met - should skip", () => { diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index 91d194b9e..4abcfbe93 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -35,4 +35,37 @@ describe("createChatParamsHandler", () => { //#then expect(called).toBe(true) }) + + test("passes the original mutable message object to chat.params hooks", async () => { + //#given + const handler = createChatParamsHandler({ + anthropicEffort: { + "chat.params": async (input) => { + input.message.variant = "high" + }, + }, + }) + + const message = { variant: "max" } + const input = { + sessionID: "ses_chat_params", + agent: { name: "sisyphus" }, + model: { providerID: "opencode", modelID: "claude-sonnet-4-6" }, + provider: { id: "opencode" }, + message, + } + + const output = { + temperature: 0.1, + topP: 1, + topK: 1, + options: {}, + } + + //#when + await handler(input, output) + + //#then + expect(message.variant).toBe("high") + }) }) diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index 14ff4ed8e..c4bd5e626 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -6,6 +6,10 @@ export type ChatParamsInput = { message: { variant?: string } } +type ChatParamsHookInput = ChatParamsInput & { + rawMessage?: Record +} + export type ChatParamsOutput = { temperature?: number topP?: number @@ -17,7 +21,7 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } -function buildChatParamsInput(raw: unknown): ChatParamsInput | null { +function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null { if (!isRecord(raw)) return null const sessionID = raw.sessionID @@ -56,7 +60,9 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null { agent: { name: agentName }, model: { providerID, modelID }, provider: { id: providerId }, - message: typeof variant === "string" ? { variant } : {}, + message, + rawMessage: message, + ...(typeof variant === "string" ? {} : {}), } } @@ -69,7 +75,7 @@ function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput { } export function createChatParamsHandler(args: { - anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise } | null + anthropicEffort: { "chat.params"?: (input: ChatParamsHookInput, output: ChatParamsOutput) => Promise } | null }): (input: unknown, output: unknown) => Promise { return async (input, output): Promise => { const normalizedInput = buildChatParamsInput(input)