Merge pull request #2606 from RaviTharuma/fix/clamp-variant-on-non-opus-fallback
fix: clamp unsupported max variant for non-Opus Claude models
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { log, normalizeModelID } from "../../shared"
|
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 {
|
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||||
@@ -8,9 +8,9 @@ function isClaudeProvider(providerID: string, modelID: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpus46(modelID: string): boolean {
|
function isOpusModel(modelID: string): boolean {
|
||||||
const normalized = normalizeModelID(modelID)
|
const normalized = normalizeModelID(modelID)
|
||||||
return OPUS_4_6_PATTERN.test(normalized)
|
return OPUS_PATTERN.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatParamsInput {
|
interface ChatParamsInput {
|
||||||
@@ -28,6 +28,20 @@ interface ChatParamsOutput {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid thinking budget levels per model tier.
|
||||||
|
* Opus supports "max"; all other Claude models cap at "high".
|
||||||
|
*/
|
||||||
|
const MAX_VARIANT_BY_TIER: Record<string, string> = {
|
||||||
|
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() {
|
export function createAnthropicEffortHook() {
|
||||||
return {
|
return {
|
||||||
"chat.params": async (
|
"chat.params": async (
|
||||||
@@ -38,15 +52,27 @@ export function createAnthropicEffortHook() {
|
|||||||
if (!model?.modelID || !model?.providerID) return
|
if (!model?.modelID || !model?.providerID) return
|
||||||
if (message.variant !== "max") return
|
if (message.variant !== "max") return
|
||||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||||
if (!isOpus46(model.modelID)) return
|
|
||||||
if (output.options.effort !== undefined) return
|
if (output.options.effort !== undefined) return
|
||||||
|
|
||||||
output.options.effort = "max"
|
const opus = isOpusModel(model.modelID)
|
||||||
log("anthropic-effort: injected effort=max", {
|
const clamped = clampVariant(message.variant, opus)
|
||||||
sessionID: input.sessionID,
|
output.options.effort = clamped
|
||||||
provider: model.providerID,
|
|
||||||
model: model.modelID,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ describe("createAnthropicEffortHook", () => {
|
|||||||
//#then should normalize and inject effort
|
//#then should normalize and inject effort
|
||||||
expect(output.options.effort).toBe("max")
|
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", () => {
|
describe("conditions NOT met - should skip", () => {
|
||||||
@@ -143,8 +158,8 @@ describe("createAnthropicEffortHook", () => {
|
|||||||
expect(output.options.effort).toBeUndefined()
|
expect(output.options.effort).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should NOT inject effort for non-opus model", async () => {
|
it("should clamp effort to high for non-opus claude model with variant max", async () => {
|
||||||
//#given claude-sonnet-4-6 (not opus)
|
//#given claude-sonnet-4-6 (not opus) with variant max
|
||||||
const hook = createAnthropicEffortHook()
|
const hook = createAnthropicEffortHook()
|
||||||
const { input, output } = createMockParams({
|
const { input, output } = createMockParams({
|
||||||
modelID: "claude-sonnet-4-6",
|
modelID: "claude-sonnet-4-6",
|
||||||
@@ -153,8 +168,9 @@ describe("createAnthropicEffortHook", () => {
|
|||||||
//#when chat.params hook is called
|
//#when chat.params hook is called
|
||||||
await hook["chat.params"](input, output)
|
await hook["chat.params"](input, output)
|
||||||
|
|
||||||
//#then effort should NOT be injected
|
//#then effort should be clamped to high (not max)
|
||||||
expect(output.options.effort).toBeUndefined()
|
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 () => {
|
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
|
||||||
|
|||||||
@@ -35,4 +35,37 @@ describe("createChatParamsHandler", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(called).toBe(true)
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export type ChatParamsInput = {
|
|||||||
message: { variant?: string }
|
message: { variant?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatParamsHookInput = ChatParamsInput & {
|
||||||
|
rawMessage?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatParamsOutput = {
|
export type ChatParamsOutput = {
|
||||||
temperature?: number
|
temperature?: number
|
||||||
topP?: number
|
topP?: number
|
||||||
@@ -17,7 +21,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null
|
return typeof value === "object" && value !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
|
function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
|
||||||
if (!isRecord(raw)) return null
|
if (!isRecord(raw)) return null
|
||||||
|
|
||||||
const sessionID = raw.sessionID
|
const sessionID = raw.sessionID
|
||||||
@@ -56,7 +60,9 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
|
|||||||
agent: { name: agentName },
|
agent: { name: agentName },
|
||||||
model: { providerID, modelID },
|
model: { providerID, modelID },
|
||||||
provider: { id: providerId },
|
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: {
|
export function createChatParamsHandler(args: {
|
||||||
anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise<void> } | null
|
anthropicEffort: { "chat.params"?: (input: ChatParamsHookInput, output: ChatParamsOutput) => Promise<void> } | null
|
||||||
}): (input: unknown, output: unknown) => Promise<void> {
|
}): (input: unknown, output: unknown) => Promise<void> {
|
||||||
return async (input, output): Promise<void> => {
|
return async (input, output): Promise<void> => {
|
||||||
const normalizedInput = buildChatParamsInput(input)
|
const normalizedInput = buildChatParamsInput(input)
|
||||||
|
|||||||
Reference in New Issue
Block a user