diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index a545768d6..7d4eca99b 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -76,23 +76,35 @@ export function createAnthropicEffortHook() { const { agent, model, message } = input if (!model?.modelID || !model?.providerID) return if (isEffortUnsupportedModel(model.modelID)) return - if (message.variant !== "max") return if (!isClaudeProvider(model.providerID, model.modelID)) return if (shouldSkipForInternalAgent(agent?.name)) return - if (output.options.effort !== undefined) return const opus = isOpusModel(model.modelID) const constrained = isConstrainedProvider(model.providerID) + + if (output.options.effort !== undefined) { + if (output.options.effort === "max" && constrained) { + const clamped = clampVariant("max", opus, constrained) + output.options.effort = clamped + ;(message as { variant?: string }).variant = clamped + log("anthropic-effort: clamped pre-set effort max→high", { + sessionID: input.sessionID, + provider: model.providerID, + model: model.modelID, + reason: "constrained-provider", + }) + } + return + } + + if (message.variant !== "max") return + const clamped = clampVariant(message.variant, opus, constrained) output.options.effort = clamped const shouldOverrideMessageVariant = !opus || constrained if (shouldOverrideMessageVariant) { - // Override the variant so OpenCode doesn't pass "max" to the API. - // Non-Opus models cap at high; Anthropic OAuth (Claude Pro/Max) also - // caps at high even on Opus because the OAuth API only accepts - // low | medium | high. ;(message as { variant?: string }).variant = clamped log("anthropic-effort: clamped variant max→high", { sessionID: input.sessionID, diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts index 129b6513e..c4ea7a297 100644 --- a/src/hooks/anthropic-effort/index.test.ts +++ b/src/hooks/anthropic-effort/index.test.ts @@ -221,6 +221,80 @@ describe("createAnthropicEffortHook", () => { }) }) + describe("#given pre-set effort via session params — regression for #3563", () => { + it("#given pre-set effort=max + variant=max + github-copilot Opus #when hook runs #then effort and variant are both clamped to high", async () => { + // given + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "github-copilot", + modelID: "claude-opus-4-7", + variant: "max", + existingOptions: { effort: "max" }, + }) + + // when + await hook["chat.params"](input, output) + + // then + expect(output.options.effort).toBe("high") + expect(input.message.variant).toBe("high") + }) + + it("#given pre-set effort=max + variant=high + github-copilot Opus #when hook runs #then effort and variant are both clamped to high", async () => { + // given — this is the cubic violation case from PR #3608: + // message.variant is NOT "max", but pre-set effort IS "max" + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "github-copilot", + modelID: "claude-opus-4-7", + variant: "high", + existingOptions: { effort: "max" }, + }) + + // when + await hook["chat.params"](input, output) + + // then — constrained provider must clamp pre-set effort=max regardless of variant + expect(output.options.effort).toBe("high") + expect(input.message.variant).toBe("high") + }) + + it("#given pre-set effort=max + non-constrained Opus #when hook runs #then effort stays max", async () => { + // given — anthropic (non-OAuth, non-constrained) Opus with pre-set effort=max + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + variant: "max", + existingOptions: { effort: "max" }, + }) + + // when + await hook["chat.params"](input, output) + + // then — non-constrained Opus keeps max + expect(output.options.effort).toBe("max") + expect(input.message.variant).toBe("max") + }) + + it("#given pre-set effort=high + github-copilot Opus #when hook runs #then effort stays high", async () => { + // given — legitimate pre-set effort=high should not be overwritten + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "github-copilot", + modelID: "claude-opus-4-7", + variant: "max", + existingOptions: { effort: "high" }, + }) + + // when + await hook["chat.params"](input, output) + + // then — high is already valid for constrained providers, don't touch it + expect(output.options.effort).toBe("high") + }) + }) + describe("#given anthropic OAuth auth (Claude Pro/Max) — regression for #3429", () => { let tempDataDir: string const originalXdgDataHome = process.env.XDG_DATA_HOME