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:
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user