diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts new file mode 100644 index 000000000..84168fbb3 --- /dev/null +++ b/src/hooks/anthropic-effort/index.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "bun:test" +import { createAnthropicEffortHook } from "./index" + +interface ChatParamsInput { + sessionID: string + agent: { name?: string } + model: { providerID: string; modelID: string; id?: string; api?: { npm?: string } } + provider: { id: string } + message: { variant?: string } +} + +interface ChatParamsOutput { + temperature?: number + topP?: number + topK?: number + options: Record +} + +function createMockParams(overrides: { + providerID?: string + modelID?: string + variant?: string + agentName?: string + existingOptions?: Record +}): { input: ChatParamsInput; output: ChatParamsOutput } { + const providerID = overrides.providerID ?? "anthropic" + const modelID = overrides.modelID ?? "claude-opus-4-6" + const variant = "variant" in overrides ? overrides.variant : "max" + const agentName = overrides.agentName ?? "sisyphus" + const existingOptions = overrides.existingOptions ?? {} + + return { + input: { + sessionID: "test-session", + agent: { name: agentName }, + model: { providerID, modelID }, + provider: { id: providerID }, + message: { variant }, + }, + output: { + temperature: 0.1, + options: { ...existingOptions }, + }, + } +} + +describe("createAnthropicEffortHook", () => { + describe("opus 4-6 with variant max", () => { + it("should inject effort max for anthropic opus-4-6 with variant max", async () => { + //#given anthropic opus-4-6 model with variant max + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({}) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should be injected into options + expect(output.options.effort).toBe("max") + }) + + it("should inject effort max for github-copilot claude-opus-4-6", async () => { + //#given github-copilot provider with claude-opus-4-6 + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "github-copilot", + modelID: "claude-opus-4-6", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should be injected (github-copilot resolves to anthropic) + expect(output.options.effort).toBe("max") + }) + + it("should inject effort max for opencode provider with claude-opus-4-6", async () => { + //#given opencode provider with claude-opus-4-6 + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "opencode", + modelID: "claude-opus-4-6", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should be injected + expect(output.options.effort).toBe("max") + }) + + it("should handle normalized model ID with dots (opus-4.6)", async () => { + //#given model ID with dots instead of hyphens + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + modelID: "claude-opus-4.6", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then should normalize and inject effort + expect(output.options.effort).toBe("max") + }) + }) + + describe("conditions NOT met - should skip", () => { + it("should NOT inject effort when variant is not max", async () => { + //#given opus-4-6 with variant high (not max) + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ variant: "high" }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should NOT be injected + expect(output.options.effort).toBeUndefined() + }) + + it("should NOT inject effort when variant is undefined", async () => { + //#given opus-4-6 with no variant + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ variant: undefined }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should NOT be injected + expect(output.options.effort).toBeUndefined() + }) + + it("should NOT inject effort for non-opus model", async () => { + //#given claude-sonnet-4-5 (not opus) + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + modelID: "claude-sonnet-4-5", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should NOT be injected + expect(output.options.effort).toBeUndefined() + }) + + it("should NOT inject effort for non-anthropic provider with non-claude model", async () => { + //#given openai provider with gpt model + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + providerID: "openai", + modelID: "gpt-5.2", + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should NOT be injected + expect(output.options.effort).toBeUndefined() + }) + }) + + describe("preserves existing options", () => { + it("should NOT overwrite existing effort if already set", async () => { + //#given options already have effort set + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + existingOptions: { effort: "high" }, + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then existing effort should be preserved + expect(output.options.effort).toBe("high") + }) + + it("should preserve other existing options when injecting effort", async () => { + //#given options with existing thinking config + const hook = createAnthropicEffortHook() + const { input, output } = createMockParams({ + existingOptions: { + thinking: { type: "enabled", budgetTokens: 31999 }, + }, + }) + + //#when chat.params hook is called + await hook["chat.params"](input, output) + + //#then effort should be added without affecting thinking + expect(output.options.effort).toBe("max") + expect(output.options.thinking).toEqual({ + type: "enabled", + budgetTokens: 31999, + }) + }) + }) +}) diff --git a/src/hooks/anthropic-effort/index.ts b/src/hooks/anthropic-effort/index.ts new file mode 100644 index 000000000..b52fc32ad --- /dev/null +++ b/src/hooks/anthropic-effort/index.ts @@ -0,0 +1,55 @@ +import { log } from "../../shared" + +const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i + +function normalizeModelID(modelID: string): string { + return modelID.replace(/\.(\d+)/g, "-$1") +} + +function isClaudeProvider(providerID: string, modelID: string): boolean { + if (["anthropic", "opencode"].includes(providerID)) return true + if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true + return false +} + +function isOpus46(modelID: string): boolean { + const normalized = normalizeModelID(modelID) + return OPUS_4_6_PATTERN.test(normalized) +} + +interface ChatParamsInput { + sessionID: string + agent: { name?: string } + model: { providerID: string; modelID: string } + provider: { id: string } + message: { variant?: string } +} + +interface ChatParamsOutput { + temperature?: number + topP?: number + topK?: number + options: Record +} + +export function createAnthropicEffortHook() { + return { + "chat.params": async ( + input: ChatParamsInput, + output: ChatParamsOutput + ): Promise => { + const { model, message } = input + 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, + }) + }, + } +}