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
+67 -2
View File
@@ -1,8 +1,17 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { createChatParamsHandler } from "./chat-params"
import {
clearSessionPromptParams,
getSessionPromptParams,
setSessionPromptParams,
} from "../shared/session-prompt-params-state"
describe("createChatParamsHandler", () => {
afterEach(() => {
clearSessionPromptParams("ses_chat_params")
})
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
//#given
let called = false
@@ -35,7 +44,6 @@ describe("createChatParamsHandler", () => {
//#then
expect(called).toBe(true)
})
test("passes the original mutable message object to chat.params hooks", async () => {
//#given
const handler = createChatParamsHandler({
@@ -68,4 +76,61 @@ describe("createChatParamsHandler", () => {
//#then
expect(message.variant).toBe("high")
})
test("applies stored prompt params for the session", async () => {
//#given
setSessionPromptParams("ses_chat_params", {
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
const handler = createChatParamsHandler({
anthropicEffort: null,
})
const input = {
sessionID: "ses_chat_params",
agent: { name: "oracle" },
model: { providerID: "openai", modelID: "gpt-5.4" },
provider: { id: "openai" },
message: {},
}
const output = {
temperature: 0.1,
topP: 1,
topK: 1,
options: { existing: true },
}
//#when
await handler(input, output)
//#then
expect(output).toEqual({
temperature: 0.4,
topP: 0.7,
topK: 1,
options: {
existing: true,
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
expect(getSessionPromptParams("ses_chat_params")).toEqual({
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
})
+18
View File
@@ -1,3 +1,5 @@
import { getSessionPromptParams } from "../shared/session-prompt-params-state"
export type ChatParamsInput = {
sessionID: string
agent: { name?: string }
@@ -82,6 +84,22 @@ export function createChatParamsHandler(args: {
if (!normalizedInput) return
if (!isChatParamsOutput(output)) return
const storedPromptParams = getSessionPromptParams(normalizedInput.sessionID)
if (storedPromptParams) {
if (storedPromptParams.temperature !== undefined) {
output.temperature = storedPromptParams.temperature
}
if (storedPromptParams.topP !== undefined) {
output.topP = storedPromptParams.topP
}
if (storedPromptParams.options) {
output.options = {
...output.options,
...storedPromptParams.options,
}
}
}
await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output)
}
}
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"
import { _resetForTesting, getSessionAgent, updateSessionAgent } from "../features/claude-code-session-state"
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
import { clearSessionPromptParams } from "../shared/session-prompt-params-state"
import { createEventHandler } from "./event"
function createMinimalEventHandler() {
@@ -53,6 +54,8 @@ describe("createEventHandler compaction agent filtering", () => {
_resetForTesting()
clearSessionModel("ses_compaction_poisoning")
clearSessionModel("ses_compaction_model_poisoning")
clearSessionPromptParams("ses_compaction_poisoning")
clearSessionPromptParams("ses_compaction_model_poisoning")
})
it("does not overwrite the stored session agent with compaction", async () => {
+40
View File
@@ -4,6 +4,7 @@ import { createEventHandler } from "./event"
import { createChatMessageHandler } from "./chat-message"
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
type EventInput = { event: { type: string; properties?: unknown } }
@@ -441,6 +442,45 @@ describe("createEventHandler - event forwarding", () => {
expect(disconnectedSessions).toEqual([sessionID])
expect(deletedSessions).toEqual([sessionID])
})
it("clears stored prompt params on session.deleted", async () => {
//#given
const eventHandler = createEventHandler({
ctx: {} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
skillMcpManager: {
disconnectSession: async () => {},
},
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as never,
hooks: {} as never,
})
const sessionID = "ses_prompt_params_deleted"
setSessionPromptParams(sessionID, {
temperature: 0.4,
topP: 0.7,
options: { reasoningEffort: "high" },
})
//#when
await eventHandler({
event: {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
})
//#then
expect(getSessionPromptParams(sessionID)).toBeUndefined()
})
})
describe("createEventHandler - retry dedupe lifecycle", () => {
+6 -4
View File
@@ -16,7 +16,7 @@ import {
setSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
import { resetMessageCursor } from "../shared";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
@@ -25,6 +25,7 @@ import { shouldRetryError } from "../shared/model-error-classifier";
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state";
import { clearSessionPromptParams } from "../shared/session-prompt-params-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
@@ -110,10 +111,10 @@ function applyUserConfiguredFallbackChain(
pluginConfig: OhMyOpenCodeConfig,
): void {
const agentKey = getAgentConfigKey(agentName);
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
if (configuredFallbackModels.length === 0) return;
const rawFallbackModels = getRawFallbackModels(sessionID, agentKey, pluginConfig);
if (!rawFallbackModels || rawFallbackModels.length === 0) return;
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
if (fallbackChain && fallbackChain.length > 0) {
setSessionFallbackChain(sessionID, fallbackChain);
@@ -330,6 +331,7 @@ export function createEventHandler(args: {
resetMessageCursor(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
clearSessionPromptParams(sessionInfo.id);
syncSubagentSessions.delete(sessionInfo.id);
if (wasSyncSubagentSession) {
subagentSessions.delete(sessionInfo.id);