diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index 16e2656c2..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 { @@ -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 = isOpusModel(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 ce974fe19..ff8377bd4 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", () => { @@ -143,8 +158,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 +168,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 () => { 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)