feat(model-settings-compat): add variant/reasoningEffort compatibility resolver

- Registry-based model family detection (provider-agnostic)
- Variant and reasoningEffort ladder downgrade logic
- Three-tier resolution: metadata override → family heuristic → unknown drop
- Comprehensive test suite covering all model families
This commit is contained in:
Ravi Tharuma
2026-03-18 20:37:42 +01:00
parent fb085538eb
commit d4f962b55d
13 changed files with 971 additions and 127 deletions
+20
View File
@@ -371,6 +371,26 @@ describe("CategoryConfigSchema", () => {
}
})
test("accepts reasoningEffort values none and minimal", () => {
// given
const noneConfig = { reasoningEffort: "none" }
const minimalConfig = { reasoningEffort: "minimal" }
// when
const noneResult = CategoryConfigSchema.safeParse(noneConfig)
const minimalResult = CategoryConfigSchema.safeParse(minimalConfig)
// then
expect(noneResult.success).toBe(true)
expect(minimalResult.success).toBe(true)
if (noneResult.success) {
expect(noneResult.data.reasoningEffort).toBe("none")
}
if (minimalResult.success) {
expect(minimalResult.data.reasoningEffort).toBe("minimal")
}
})
test("rejects non-string variant", () => {
// given
const config = { model: "openai/gpt-5.4", variant: 123 }
+1 -1
View File
@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
})
.optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */
+1 -1
View File
@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
budgetTokens: z.number().optional(),
})
.optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),
+1 -1
View File
@@ -1,6 +1,6 @@
import { log, normalizeModelID } from "../../shared"
const OPUS_PATTERN = /claude-opus/i
const OPUS_PATTERN = /claude-.*opus/i
function isClaudeProvider(providerID: string, modelID: string): boolean {
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
+15 -118
View File
@@ -45,75 +45,31 @@ function createMockParams(overrides: {
}
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
describe("opus family with variant max", () => {
it("injects effort max for anthropic opus-4-6", async () => {
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
it("injects effort max for another opus family model such as opus-4-5", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-opus-4-6",
})
const { input, output } = createMockParams({ modelID: "claude-opus-4-5" })
//#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
it("injects effort max for dotted opus ids", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "opencode",
modelID: "claude-opus-4-6",
})
const { input, output } = createMockParams({ 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 inject effort max for google-vertex-anthropic provider", async () => {
//#given google-vertex-anthropic provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "google-vertex-anthropic",
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")
})
@@ -133,39 +89,30 @@ describe("createAnthropicEffortHook", () => {
})
})
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)
describe("skip conditions", () => {
it("does nothing when variant is not max", async () => {
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
it("does nothing when variant is undefined", async () => {
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 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",
})
const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be clamped to high (not max)
@@ -173,74 +120,24 @@ describe("createAnthropicEffortHook", () => {
expect(input.message.variant).toBe("high")
})
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
//#given openai provider with gpt model
it("does nothing for non-claude providers/models", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "openai",
modelID: "gpt-5.4",
})
const { input, output } = createMockParams({ providerID: "openai", modelID: "gpt-5.4" })
//#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 throw when model.modelID is undefined", async () => {
//#given model with undefined modelID (runtime edge case)
const hook = createAnthropicEffortHook()
const input = {
sessionID: "test-session",
agent: { name: "sisyphus" },
model: { providerID: "anthropic", modelID: undefined as unknown as string },
provider: { id: "anthropic" },
message: { variant: "max" as const },
}
const output = { temperature: 0.1, options: {} }
//#when chat.params hook is called with undefined modelID
await hook["chat.params"](input, output)
//#then should gracefully skip without throwing
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
describe("existing options", () => {
it("does not overwrite existing effort", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: { effort: "high" },
})
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,
})
})
})
})
+7 -5
View File
@@ -32,7 +32,13 @@ export function createPluginInterface(args: {
return {
tool: tools,
"chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }),
"chat.params": async (input: unknown, output: unknown) => {
const handler = createChatParamsHandler({
anthropicEffort: hooks.anthropicEffort,
client: ctx.client,
})
await handler(input, output)
},
"chat.headers": createChatHeadersHandler({ ctx }),
@@ -68,9 +74,5 @@ export function createPluginInterface(args: {
ctx,
hooks,
}),
"tool.definition": async (input, output) => {
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
},
}
}
+82 -1
View File
@@ -1,4 +1,6 @@
import { normalizeSDKResponse } from "../shared/normalize-sdk-response"
import { getSessionPromptParams } from "../shared/session-prompt-params-state"
import { resolveCompatibleModelSettings } from "../shared"
export type ChatParamsInput = {
sessionID: string
@@ -19,6 +21,25 @@ export type ChatParamsOutput = {
options: Record<string, unknown>
}
type ProviderListClient = {
provider?: {
list?: () => Promise<unknown>
}
}
type ProviderModelMetadata = {
variants?: Record<string, unknown>
}
type ProviderListEntry = {
id?: string
models?: Record<string, ProviderModelMetadata>
}
type ProviderListData = {
all?: ProviderListEntry[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
@@ -49,7 +70,11 @@ function buildChatParamsInput(raw: unknown): ChatParamsHookInput | null {
if (!agentName) return null
const providerID = model.providerID
const modelID = model.modelID
const modelID = typeof model.modelID === "string"
? model.modelID
: typeof model.id === "string"
? model.id
: undefined
const providerId = provider.id
const variant = message.variant
@@ -76,8 +101,33 @@ function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput {
return isRecord(raw.options)
}
async function getVariantCapabilities(
client: ProviderListClient | undefined,
model: { providerID: string; modelID: string },
): Promise<string[] | undefined> {
const providerList = client?.provider?.list
if (typeof providerList !== "function") {
return undefined
}
try {
const response = await providerList()
const data = normalizeSDKResponse<ProviderListData>(response, {})
const providerEntry = data.all?.find((entry) => entry.id === model.providerID)
const variants = providerEntry?.models?.[model.modelID]?.variants
if (!variants) {
return undefined
}
return Object.keys(variants)
} catch {
return undefined
}
}
export function createChatParamsHandler(args: {
anthropicEffort: { "chat.params"?: (input: ChatParamsHookInput, output: ChatParamsOutput) => Promise<void> } | null
client?: ProviderListClient
}): (input: unknown, output: unknown) => Promise<void> {
return async (input, output): Promise<void> => {
const normalizedInput = buildChatParamsInput(input)
@@ -100,6 +150,37 @@ export function createChatParamsHandler(args: {
}
}
const variantCapabilities = await getVariantCapabilities(args.client, normalizedInput.model)
const compatibility = resolveCompatibleModelSettings({
providerID: normalizedInput.model.providerID,
modelID: normalizedInput.model.modelID,
desired: {
variant: normalizedInput.message.variant,
reasoningEffort: typeof output.options.reasoningEffort === "string"
? output.options.reasoningEffort
: undefined,
},
capabilities: {
variants: variantCapabilities,
},
})
if (normalizedInput.rawMessage) {
if (compatibility.variant !== undefined) {
normalizedInput.rawMessage.variant = compatibility.variant
} else {
delete normalizedInput.rawMessage.variant
}
}
normalizedInput.message = normalizedInput.rawMessage as { variant?: string }
if (compatibility.reasoningEffort !== undefined) {
output.options.reasoningEffort = compatibility.reasoningEffort
} else if ("reasoningEffort" in output.options) {
delete output.options.reasoningEffort
}
await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output)
}
}
+1
View File
@@ -43,6 +43,7 @@ export type {
ModelResolutionResult,
} from "./model-resolution-types"
export * from "./model-availability"
export * from "./model-settings-compatibility"
export * from "./fallback-model-availability"
export * from "./connected-providers-cache"
export * from "./context-limit-resolver"
@@ -0,0 +1,302 @@
import { describe, expect, test } from "bun:test"
import { resolveCompatibleModelSettings } from "./model-settings-compatibility"
describe("resolveCompatibleModelSettings", () => {
test("keeps supported Claude Opus variant unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-6",
desired: { variant: "max" },
})
expect(result).toEqual({
variant: "max",
reasoningEffort: undefined,
changes: [],
})
})
test("uses model metadata first for variant support", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-6",
desired: { variant: "max" },
capabilities: { variants: ["low", "medium", "high"] },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-metadata",
},
],
})
})
test("prefers metadata over family heuristics even when family would allow a higher level", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-opus-4-6",
desired: { variant: "max" },
capabilities: { variants: ["low", "medium"] },
})
expect(result.variant).toBe("medium")
expect(result.changes).toEqual([
{
field: "variant",
from: "max",
to: "medium",
reason: "unsupported-by-model-metadata",
},
])
})
test("downgrades unsupported Claude Sonnet max variant to high when metadata is absent", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { variant: "max" },
})
expect(result.variant).toBe("high")
expect(result.changes).toEqual([
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("keeps supported GPT reasoningEffort unchanged", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "high",
changes: [],
})
})
test("downgrades gpt-5 reasoningEffort max to xhigh", () => {
// given
const input = {
providerID: "openai",
modelID: "gpt-5.4",
desired: { reasoningEffort: "max" },
}
// when
const result = resolveCompatibleModelSettings(input)
// then
expect(result.reasoningEffort).toBe("xhigh")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "max",
to: "xhigh",
reason: "unsupported-by-model-family",
},
])
})
test("keeps supported OpenAI reasoning-family effort for o-series models", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "o3-mini",
desired: { reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: "high",
changes: [],
})
})
test("downgrades openai reasoning-family effort xhigh to high", () => {
// given
const input = {
providerID: "openai",
modelID: "o3-mini",
desired: { reasoningEffort: "xhigh" },
}
// when
const result = resolveCompatibleModelSettings(input)
// then
expect(result.reasoningEffort).toBe("high")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "xhigh",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("drops reasoningEffort for gpt-5 mini models", () => {
// given
const input = {
providerID: "openai",
modelID: "gpt-5.4-mini",
desired: { reasoningEffort: "high" },
}
// when
const result = resolveCompatibleModelSettings(input)
// then
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
])
})
test("treats non-openai o-series models as unknown", () => {
// given
const input = {
providerID: "ollama",
modelID: "o3",
desired: { reasoningEffort: "high" },
}
// when
const result = resolveCompatibleModelSettings(input)
// then
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unknown-model-family",
},
])
})
test("does not record case-only normalization as a compatibility downgrade", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { variant: "HIGH", reasoningEffort: "HIGH" },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: "high",
changes: [],
})
})
test("downgrades unsupported GPT reasoningEffort to nearest lower level", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-4.1",
desired: { reasoningEffort: "xhigh" },
})
expect(result.reasoningEffort).toBe("high")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "xhigh",
to: "high",
reason: "unsupported-by-model-family",
},
])
})
test("drops reasoningEffort for Claude family", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { reasoningEffort: "high" },
})
expect(result.reasoningEffort).toBeUndefined()
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
])
})
test("handles combined variant and reasoningEffort normalization", () => {
const result = resolveCompatibleModelSettings({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
desired: { variant: "max", reasoningEffort: "high" },
})
expect(result).toEqual({
variant: "high",
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: "high",
reason: "unsupported-by-model-family",
},
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unsupported-by-model-family",
},
],
})
})
test("treats unknown model families conservatively by dropping unsupported settings", () => {
const result = resolveCompatibleModelSettings({
providerID: "mystery",
modelID: "mystery-model-1",
desired: { variant: "max", reasoningEffort: "high" },
})
expect(result).toEqual({
variant: undefined,
reasoningEffort: undefined,
changes: [
{
field: "variant",
from: "max",
to: undefined,
reason: "unknown-model-family",
},
{
field: "reasoningEffort",
from: "high",
to: undefined,
reason: "unknown-model-family",
},
],
})
})
})
+260
View File
@@ -0,0 +1,260 @@
import { normalizeModelID } from "./model-normalization"
type CompatibilityField = "variant" | "reasoningEffort"
type DesiredModelSettings = {
variant?: string
reasoningEffort?: string
}
type VariantCapabilities = {
variants?: string[]
}
export type ModelSettingsCompatibilityInput = {
providerID: string
modelID: string
desired: DesiredModelSettings
capabilities?: VariantCapabilities
}
export type ModelSettingsCompatibilityChange = {
field: CompatibilityField
from: string
to?: string
reason: "unsupported-by-model-family" | "unknown-model-family" | "unsupported-by-model-metadata"
}
export type ModelSettingsCompatibilityResult = {
variant?: string
reasoningEffort?: string
changes: ModelSettingsCompatibilityChange[]
}
type ModelFamily = "claude-opus" | "claude-non-opus" | "openai-reasoning" | "gpt-5" | "gpt-5-mini" | "gpt-legacy" | "unknown"
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
function detectModelFamily(providerID: string, modelID: string): ModelFamily {
const provider = providerID.toLowerCase()
const model = normalizeModelID(modelID).toLowerCase()
const isClaudeProvider = [
"anthropic",
"google-vertex-anthropic",
"aws-bedrock-anthropic",
].includes(provider)
|| (["github-copilot", "opencode", "aws-bedrock", "bedrock"].includes(provider) && model.includes("claude"))
if (isClaudeProvider) {
return /claude(?:-\d+(?:-\d+)*)?-opus/.test(model) ? "claude-opus" : "claude-non-opus"
}
const isOpenAiReasoningFamily = provider === "openai" && (/^o\d(?:$|-)/.test(model) || model.includes("reasoning"))
if (isOpenAiReasoningFamily) {
return "openai-reasoning"
}
if (/gpt-5.*-mini/.test(model)) {
return "gpt-5-mini"
}
if (model.includes("gpt-5")) {
return "gpt-5"
}
if (model.includes("gpt") || (provider === "openai" && /^o\d(?:$|-)/.test(model))) {
return "gpt-legacy"
}
return "unknown"
}
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
const requestedIndex = ladder.indexOf(value)
if (requestedIndex === -1) return undefined
for (let index = requestedIndex; index >= 0; index -= 1) {
const candidate = ladder[index]
if (allowed.includes(candidate)) {
return candidate
}
}
return undefined
}
function normalizeCapabilitiesVariants(capabilities: VariantCapabilities | undefined): string[] | undefined {
if (!capabilities?.variants || capabilities.variants.length === 0) {
return undefined
}
return capabilities.variants.map((variant) => variant.toLowerCase())
}
function resolveVariant(
modelFamily: ModelFamily,
variant: string,
capabilities?: VariantCapabilities,
): { value?: string; reason?: ModelSettingsCompatibilityChange["reason"] } {
const normalized = variant.toLowerCase()
const metadataVariants = normalizeCapabilitiesVariants(capabilities)
if (metadataVariants) {
if (metadataVariants.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, metadataVariants, VARIANT_LADDER),
reason: "unsupported-by-model-metadata",
}
}
if (modelFamily === "claude-opus") {
const allowed = ["low", "medium", "high", "max"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, VARIANT_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "claude-non-opus") {
const allowed = ["low", "medium", "high"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, VARIANT_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "gpt-5" || modelFamily === "gpt-5-mini") {
const allowed = ["low", "medium", "high", "xhigh", "max"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, VARIANT_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "openai-reasoning") {
const allowed = ["low", "medium", "high"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, VARIANT_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "gpt-legacy") {
const allowed = ["low", "medium", "high"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, VARIANT_LADDER),
reason: "unsupported-by-model-family",
}
}
return { value: undefined, reason: "unknown-model-family" }
}
function resolveReasoningEffort(modelFamily: ModelFamily, reasoningEffort: string): { value?: string; reason?: ModelSettingsCompatibilityChange["reason"] } {
const normalized = reasoningEffort.toLowerCase()
if (modelFamily === "gpt-5") {
const allowed = ["none", "minimal", "low", "medium", "high", "xhigh"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, REASONING_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "gpt-5-mini") {
return { value: undefined, reason: "unsupported-by-model-family" }
}
if (modelFamily === "openai-reasoning") {
const allowed = ["none", "minimal", "low", "medium", "high"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, REASONING_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "gpt-legacy") {
const allowed = ["none", "minimal", "low", "medium", "high"]
if (allowed.includes(normalized)) {
return { value: normalized }
}
return {
value: downgradeWithinLadder(normalized, allowed, REASONING_LADDER),
reason: "unsupported-by-model-family",
}
}
if (modelFamily === "claude-opus" || modelFamily === "claude-non-opus") {
return { value: undefined, reason: "unsupported-by-model-family" }
}
return { value: undefined, reason: "unknown-model-family" }
}
export function resolveCompatibleModelSettings(
input: ModelSettingsCompatibilityInput,
): ModelSettingsCompatibilityResult {
const modelFamily = detectModelFamily(input.providerID, input.modelID)
const changes: ModelSettingsCompatibilityChange[] = []
let variant = input.desired.variant
if (variant !== undefined) {
const normalizedVariant = variant.toLowerCase()
const resolved = resolveVariant(modelFamily, normalizedVariant, input.capabilities)
if (resolved.value !== normalizedVariant && resolved.reason) {
changes.push({
field: "variant",
from: variant,
to: resolved.value,
reason: resolved.reason,
})
}
variant = resolved.value
}
let reasoningEffort = input.desired.reasoningEffort
if (reasoningEffort !== undefined) {
const normalizedReasoningEffort = reasoningEffort.toLowerCase()
const resolved = resolveReasoningEffort(modelFamily, normalizedReasoningEffort)
if (resolved.value !== normalizedReasoningEffort && resolved.reason) {
changes.push({
field: "reasoningEffort",
from: reasoningEffort,
to: resolved.value,
reason: resolved.reason,
})
}
reasoningEffort = resolved.value
}
return {
variant,
reasoningEffort,
changes,
}
}
@@ -0,0 +1,31 @@
import { clearSessionPromptParams, setSessionPromptParams } from "./session-prompt-params-state"
type PromptParamModel = {
temperature?: number
top_p?: number
reasoningEffort?: string
maxTokens?: number
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
}
export function applySessionPromptParams(
sessionID: string,
model: PromptParamModel | undefined,
): void {
if (!model) {
clearSessionPromptParams(sessionID)
return
}
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}
setSessionPromptParams(sessionID, {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
})
}