feat(config): support object-style fallback_models with per-model settings

Add support for object-style entries in fallback_models arrays, enabling
per-model configuration of variant, reasoningEffort, temperature, top_p,
maxTokens, and thinking settings.

- Zod schema for FallbackModelObject with full validation
- normalizeFallbackModels() and flattenToFallbackModelStrings() utilities
- Provider-agnostic model resolution pipeline with fallback chain
- Session prompt params state management
- Fallback chain construction with prefix-match lookup
- Integration across delegate-task, background-agent, and plugin layers
This commit is contained in:
Ravi Tharuma
2026-03-18 14:21:27 +01:00
parent 7761e48dca
commit a77a16c494
34 changed files with 1686 additions and 126 deletions
+336 -2
View File
@@ -1,6 +1,13 @@
import { describe, test, expect } from "bun:test"
import { buildFallbackChainFromModels, parseFallbackModelEntry } from "./fallback-chain-from-models"
import { describe, test, it, expect } from "bun:test"
import {
parseFallbackModelEntry,
parseFallbackModelObjectEntry,
buildFallbackChainFromModels,
findMostSpecificFallbackEntry,
} from "./fallback-chain-from-models"
import { flattenToFallbackModelStrings } from "./model-resolver"
// Upstream tests
describe("fallback-chain-from-models", () => {
test("parses provider/model entry with parenthesized variant", () => {
//#given
@@ -61,3 +68,330 @@ describe("fallback-chain-from-models", () => {
])
})
})
// Object-style entry tests
describe("parseFallbackModelEntry (extended)", () => {
it("parses provider/model string", () => {
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6", undefined)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
})
})
it("parses model with parenthesized variant", () => {
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6(high)", undefined)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
variant: "high",
})
})
it("parses model with space variant", () => {
const result = parseFallbackModelEntry("openai/gpt-5.4 xhigh", undefined)
expect(result).toEqual({
providers: ["openai"],
model: "gpt-5.4",
variant: "xhigh",
})
})
it("parses model with minimal space variant", () => {
const result = parseFallbackModelEntry("openai/gpt-5.4 minimal", undefined)
expect(result).toEqual({
providers: ["openai"],
model: "gpt-5.4",
variant: "minimal",
})
})
it("uses context provider when no provider prefix", () => {
const result = parseFallbackModelEntry("claude-sonnet-4-6", "anthropic")
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
})
})
it("returns undefined for empty string", () => {
expect(parseFallbackModelEntry("", undefined)).toBeUndefined()
expect(parseFallbackModelEntry(" ", undefined)).toBeUndefined()
})
})
describe("parseFallbackModelObjectEntry", () => {
it("parses object with model only", () => {
const result = parseFallbackModelObjectEntry(
{ model: "anthropic/claude-sonnet-4-6" },
undefined,
)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
})
})
it("parses object with variant override", () => {
const result = parseFallbackModelObjectEntry(
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
undefined,
)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
variant: "high",
})
})
it("object variant overrides inline variant", () => {
const result = parseFallbackModelObjectEntry(
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
undefined,
)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
variant: "high",
})
})
it("carries reasoningEffort and temperature", () => {
const result = parseFallbackModelObjectEntry(
{
model: "openai/gpt-5.4",
variant: "high",
reasoningEffort: "high",
temperature: 0.5,
},
undefined,
)
expect(result).toEqual({
providers: ["openai"],
model: "gpt-5.4",
variant: "high",
reasoningEffort: "high",
temperature: 0.5,
})
})
it("carries thinking config", () => {
const result = parseFallbackModelObjectEntry(
{
model: "anthropic/claude-sonnet-4-6",
thinking: { type: "enabled", budgetTokens: 10000 },
},
undefined,
)
expect(result).toEqual({
providers: ["anthropic"],
model: "claude-sonnet-4-6",
thinking: { type: "enabled", budgetTokens: 10000 },
})
})
it("carries all optional fields", () => {
const result = parseFallbackModelObjectEntry(
{
model: "openai/gpt-5.4",
variant: "xhigh",
reasoningEffort: "xhigh",
temperature: 0.3,
top_p: 0.9,
maxTokens: 8192,
thinking: { type: "disabled" },
},
undefined,
)
expect(result).toEqual({
providers: ["openai"],
model: "gpt-5.4",
variant: "xhigh",
reasoningEffort: "xhigh",
temperature: 0.3,
top_p: 0.9,
maxTokens: 8192,
thinking: { type: "disabled" },
})
})
})
describe("buildFallbackChainFromModels (mixed)", () => {
it("handles string input", () => {
const result = buildFallbackChainFromModels("anthropic/claude-sonnet-4-6", undefined)
expect(result).toEqual([
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
])
})
it("handles string array", () => {
const result = buildFallbackChainFromModels(
["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
undefined,
)
expect(result).toEqual([
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
{ providers: ["openai"], model: "gpt-5.4" },
])
})
it("handles mixed array of strings and objects", () => {
const result = buildFallbackChainFromModels(
[
{ model: "anthropic/claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
{ model: "openai/gpt-5.4", reasoningEffort: "xhigh" },
"chutes/kimi-k2.5",
{ model: "chutes/glm-5", temperature: 0.7 },
"google/gemini-3-flash",
],
undefined,
)
expect(result).toEqual([
{ providers: ["anthropic"], model: "claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
{ providers: ["openai"], model: "gpt-5.4", reasoningEffort: "xhigh" },
{ providers: ["chutes"], model: "kimi-k2.5" },
{ providers: ["chutes"], model: "glm-5", temperature: 0.7 },
{ providers: ["google"], model: "gemini-3-flash" },
])
})
it("returns undefined for empty/undefined input", () => {
expect(buildFallbackChainFromModels(undefined, undefined)).toBeUndefined()
expect(buildFallbackChainFromModels([], undefined)).toBeUndefined()
})
it("filters out invalid entries", () => {
const result = buildFallbackChainFromModels(
["", "anthropic/claude-sonnet-4-6", " "],
undefined,
)
expect(result).toEqual([
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
])
})
})
describe("flattenToFallbackModelStrings", () => {
it("returns undefined for undefined input", () => {
expect(flattenToFallbackModelStrings(undefined)).toBeUndefined()
})
it("passes through plain strings", () => {
expect(flattenToFallbackModelStrings(["anthropic/claude-sonnet-4-6"])).toEqual([
"anthropic/claude-sonnet-4-6",
])
})
it("flattens object with explicit variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
})
it("preserves inline variant when no explicit variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "anthropic/claude-sonnet-4-6(high)" },
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
})
it("explicit variant overrides inline variant (no double-suffix)", () => {
expect(flattenToFallbackModelStrings([
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
})
it("explicit variant overrides space-suffix variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "openai/gpt-5.4 high", variant: "low" },
])).toEqual(["openai/gpt-5.4(low)"])
})
it("explicit variant overrides minimal space-suffix variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "openai/gpt-5.4 minimal", variant: "low" },
])).toEqual(["openai/gpt-5.4(low)"])
})
it("preserves trailing non-variant suffixes when adding explicit variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "openai/gpt-5.4 preview", variant: "low" },
])).toEqual(["openai/gpt-5.4 preview(low)"])
})
it("flattens object without variant", () => {
expect(flattenToFallbackModelStrings([
{ model: "openai/gpt-5.4" },
])).toEqual(["openai/gpt-5.4"])
})
it("handles mixed array", () => {
expect(flattenToFallbackModelStrings([
"anthropic/claude-sonnet-4-6",
{ model: "openai/gpt-5.4", variant: "high" },
{ model: "google/gemini-3-flash(low)" },
])).toEqual([
"anthropic/claude-sonnet-4-6",
"openai/gpt-5.4(high)",
"google/gemini-3-flash(low)",
])
})
})
describe("findMostSpecificFallbackEntry", () => {
it("picks exact match over prefix match", () => {
const chain = [
{ providers: ["openai"], model: "gpt-5.4" },
{ providers: ["openai"], model: "gpt-5.4-preview" },
]
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
expect(result?.model).toBe("gpt-5.4-preview")
})
it("returns prefix match when no exact match exists", () => {
const chain = [
{ providers: ["openai"], model: "gpt-5.4" },
]
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
expect(result?.model).toBe("gpt-5.4")
})
it("returns undefined when no entry matches", () => {
const chain = [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
]
expect(findMostSpecificFallbackEntry("openai", "gpt-5.4", chain)).toBeUndefined()
})
it("sorts by matched prefix length, not insertion order", () => {
// Both entries share the same provider so both match as prefixes;
// the longer (more-specific) prefix must win regardless of array order.
const chain = [
{ providers: ["openai"], model: "gpt-5" },
{ providers: ["openai"], model: "gpt-5.4-preview" },
]
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview-2026", chain)
expect(result?.model).toBe("gpt-5.4-preview")
})
it("is case-insensitive", () => {
const chain = [
{ providers: ["OpenAI"], model: "GPT-5.4" },
]
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
expect(result?.model).toBe("GPT-5.4")
})
it("preserves variant and settings from matched entry", () => {
const chain = [
{ providers: ["openai"], model: "gpt-5.4", variant: "high", temperature: 0.7 },
{ providers: ["openai"], model: "gpt-5.4-preview", variant: "low", reasoningEffort: "medium" },
]
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
expect(result).toEqual({
providers: ["openai"],
model: "gpt-5.4-preview",
variant: "low",
reasoningEffort: "medium",
})
})
})
+61 -13
View File
@@ -1,16 +1,7 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "../config/schema/fallback-models"
import { normalizeFallbackModels } from "./model-resolver"
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"none",
"auto",
"thinking",
])
import { KNOWN_VARIANTS } from "./known-variants"
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
const trimmedModel = rawModel.trim()
@@ -61,8 +52,60 @@ export function parseFallbackModelEntry(
}
}
export function parseFallbackModelObjectEntry(
obj: FallbackModelObject,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
// Reuse the string-based parser for provider/model/variant extraction.
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
if (!base) return undefined
return {
...base,
// Explicit object variant overrides any inline variant in the model string.
variant: obj.variant ?? base.variant,
reasoningEffort: obj.reasoningEffort,
temperature: obj.temperature,
top_p: obj.top_p,
maxTokens: obj.maxTokens,
thinking: obj.thinking,
}
}
/**
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
* the resolved `provider/modelID`. Longest match wins so that e.g.
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
* the shorter `openai/gpt-5.4`.
*/
export function findMostSpecificFallbackEntry(
providerID: string,
modelID: string,
chain: FallbackEntry[],
): FallbackEntry | undefined {
const resolved = `${providerID}/${modelID}`.toLowerCase()
// Collect entries whose provider/model is a prefix of the resolved model,
// together with the length of the matching prefix (longest match wins).
const matches: { entry: FallbackEntry; matchLen: number }[] = []
for (const entry of chain) {
for (const p of entry.providers) {
const candidate = `${p}/${entry.model}`.toLowerCase()
if (resolved.startsWith(candidate)) {
matches.push({ entry, matchLen: candidate.length })
break // one match per entry is enough
}
}
}
if (matches.length === 0) return undefined
matches.sort((a, b) => b.matchLen - a.matchLen)
return matches[0].entry
}
export function buildFallbackChainFromModels(
fallbackModels: string | string[] | undefined,
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry[] | undefined {
@@ -70,7 +113,12 @@ export function buildFallbackChainFromModels(
if (!normalized || normalized.length === 0) return undefined
const parsed = normalized
.map((model) => parseFallbackModelEntry(model, contextProviderID, defaultProviderID))
.map((entry) => {
if (typeof entry === "string") {
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
}
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
})
.filter((entry): entry is FallbackEntry => entry !== undefined)
if (parsed.length === 0) return undefined
+1 -1
View File
@@ -35,7 +35,7 @@ export * from "./agent-tool-restrictions"
export * from "./model-requirements"
export * from "./model-resolver"
export { normalizeModel, normalizeModelID } from "./model-normalization"
export { normalizeFallbackModels } from "./model-resolver"
export { normalizeFallbackModels, flattenToFallbackModelStrings } from "./model-resolver"
export { resolveModelPipeline } from "./model-resolution-pipeline"
export type {
ModelResolutionRequest,
+16
View File
@@ -0,0 +1,16 @@
/**
* Canonical set of recognised variant / effort tokens.
* Used by parseFallbackModelEntry (space-suffix detection) and
* flattenToFallbackModelStrings (inline-variant stripping).
*/
export const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
+5
View File
@@ -2,6 +2,11 @@ export type FallbackEntry = {
providers: string[];
model: string;
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max)
reasoningEffort?: string;
temperature?: number;
top_p?: number;
maxTokens?: number;
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
};
export type ModelRequirement = {
+39 -3
View File
@@ -1,6 +1,8 @@
import type { FallbackEntry } from "./model-requirements"
import type { FallbackModelObject } from "../config/schema/fallback-models"
import { normalizeModel } from "./model-normalization"
import { resolveModelPipeline } from "./model-resolution-pipeline"
import { KNOWN_VARIANTS } from "./known-variants"
export type ModelResolutionInput = {
userModel?: string
@@ -61,11 +63,45 @@ export function resolveModelWithFallback(
}
/**
* Normalizes fallback_models config (which can be string or string[]) to string[]
* Centralized helper to avoid duplicated normalization logic
* Normalizes fallback_models config to a mixed array.
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
*/
export function normalizeFallbackModels(models: string | string[] | undefined): string[] | undefined {
export function normalizeFallbackModels(
models: string | (string | FallbackModelObject)[] | undefined,
): (string | FallbackModelObject)[] | undefined {
if (!models) return undefined
if (typeof models === "string") return [models]
return models
}
/**
* Extracts plain model strings from a mixed fallback models array.
* Object entries are flattened to "model" or "model(variant)" strings.
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
*/
export function flattenToFallbackModelStrings(
models: (string | FallbackModelObject)[] | undefined,
): string[] | undefined {
if (!models) return undefined
return models.map((entry) => {
if (typeof entry === "string") return entry
const variant = entry.variant
if (variant) {
// Strip any supported inline variant syntax before appending explicit override.
// Supports both parenthesized and space-suffix forms so we don't emit
// invalid strings like "provider/model high(low)".
const model = entry.model
.replace(/\([^()]+\)\s*$/, "")
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (match, suffix) => {
const normalized = String(suffix).toLowerCase()
return KNOWN_VARIANTS.has(normalized)
? ""
: match
})
.trim()
return `${model}(${variant})`
}
// No explicit variant — preserve model string as-is (including any inline variant)
return entry.model
})
}
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, test } from "bun:test"
import {
clearAllSessionPromptParams,
clearSessionPromptParams,
getSessionPromptParams,
setSessionPromptParams,
} from "./session-prompt-params-state"
describe("session-prompt-params-state", () => {
afterEach(() => {
clearAllSessionPromptParams()
})
test("stores and returns prompt params by session", () => {
//#given
const sessionID = "ses_prompt_params"
const params = {
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
maxTokens: 4096,
},
}
//#when
setSessionPromptParams(sessionID, params)
//#then
expect(getSessionPromptParams(sessionID)).toEqual(params)
})
test("returns copies so callers cannot mutate stored state", () => {
//#given
const sessionID = "ses_prompt_params_copy"
setSessionPromptParams(sessionID, {
temperature: 0.2,
options: { reasoningEffort: "medium" },
})
//#when
const result = getSessionPromptParams(sessionID)!
result.temperature = 0.9
result.options!.reasoningEffort = "max"
//#then
expect(getSessionPromptParams(sessionID)).toEqual({
temperature: 0.2,
options: { reasoningEffort: "medium" },
})
})
test("clears a single session", () => {
//#given
const sessionID = "ses_prompt_params_clear"
setSessionPromptParams(sessionID, { topP: 0.5 })
//#when
clearSessionPromptParams(sessionID)
//#then
expect(getSessionPromptParams(sessionID)).toBeUndefined()
})
})
+34
View File
@@ -0,0 +1,34 @@
export type SessionPromptParams = {
temperature?: number
topP?: number
options?: Record<string, unknown>
}
const sessionPromptParams = new Map<string, SessionPromptParams>()
export function setSessionPromptParams(sessionID: string, params: SessionPromptParams): void {
sessionPromptParams.set(sessionID, {
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
...(params.topP !== undefined ? { topP: params.topP } : {}),
...(params.options !== undefined ? { options: { ...params.options } } : {}),
})
}
export function getSessionPromptParams(sessionID: string): SessionPromptParams | undefined {
const params = sessionPromptParams.get(sessionID)
if (!params) return undefined
return {
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
...(params.topP !== undefined ? { topP: params.topP } : {}),
...(params.options !== undefined ? { options: { ...params.options } } : {}),
}
}
export function clearSessionPromptParams(sessionID: string): void {
sessionPromptParams.delete(sessionID)
}
export function clearAllSessionPromptParams(): void {
sessionPromptParams.clear()
}