diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index af8d17d19..6e0c97ac6 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -28,6 +28,7 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import { parse } from "jsonc-parser"; import ModelPickerModal from "./components/model-picker-modal"; +import AiDefaultsModal from "./components/ai-defaults-modal"; import ResetModal from "./components/reset-modal"; import WorkspaceSwitchOverlay from "./components/workspace-switch-overlay"; import CreateRemoteWorkspaceModal from "./components/create-remote-workspace-modal"; @@ -70,6 +71,14 @@ import { mapConfigProvidersToList, providerPriorityRank, } from "./utils/providers"; +import { + coerceModelVariantForModel, + findProviderModelByRef, + formatModelVariantLabel, + getModelStyleOptions, + getModelStyleSummary, + normalizeModelVariant, +} from "./utils/model-style"; import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "./types"; import type { Client, @@ -1694,7 +1703,7 @@ export default function App() { const model = selectedSessionModel(); const agent = selectedSessionAgent(); const parts = await buildPromptParts(resolvedDraft); - const selectedVariant = modelVariant() ?? undefined; + const selectedVariant = coerceModelVariantForModel(model, providers(), modelVariant()) ?? undefined; const reasoningEffort = resolveCodexReasoningEffort(model.modelID, selectedVariant ?? null); const requestVariant = reasoningEffort ? undefined : selectedVariant; const promptOverrides = reasoningEffort @@ -2701,22 +2710,8 @@ export default function App() { const [autoCompactContext, setAutoCompactContext] = createSignal(false); const [modelVariant, setModelVariant] = createSignal(null); const [autoCompactingSessionId, setAutoCompactingSessionId] = createSignal(null); - - const MODEL_VARIANT_OPTIONS = [ - { value: "none", label: "None" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "X-High" }, - ]; - - const normalizeModelVariant = (value: string | null) => { - if (!value) return null; - const trimmed = value.trim().toLowerCase(); - if (trimmed === "balance" || trimmed === "balanced") return "none"; - const match = MODEL_VARIANT_OPTIONS.find((option) => option.value === trimmed); - return match ? match.value : null; - }; + const [aiDefaultsModalOpen, setAiDefaultsModalOpen] = createSignal(false); + const [reopenAiDefaultsAfterModelPicker, setReopenAiDefaultsAfterModelPicker] = createSignal(false); const resolveCodexReasoningEffort = (modelID: string, variant: string | null) => { if (!modelID.trim().toLowerCase().includes("codex")) return undefined; @@ -2726,25 +2721,6 @@ export default function App() { return normalized; }; - const formatModelVariantLabel = (value: string | null) => { - const normalized = normalizeModelVariant(value) ?? "none"; - return MODEL_VARIANT_OPTIONS.find((option) => option.value === normalized)?.label ?? "None"; - }; - - const handleEditModelVariant = () => { - const next = window.prompt( - "Model variant (none, low, medium, high, xhigh)", - normalizeModelVariant(modelVariant()) ?? "none" - ); - if (next == null) return; - const normalized = normalizeModelVariant(next); - if (!normalized) { - window.alert("Variant must be one of: none, low, medium, high, xhigh."); - return; - } - setModelVariant(normalized); - }; - const workspaceStore = createWorkspaceStore({ startupPreference, setStartupPreference, @@ -4788,6 +4764,44 @@ export default function App() { }); }); + const defaultModelInfo = createMemo(() => + findProviderModelByRef(defaultModel(), providers()) + ); + + const defaultAnswerStyleOptions = createMemo(() => { + if (!defaultModelInfo()) return []; + return getModelStyleOptions(defaultModel(), providers()); + }); + + const defaultAnswerStyleSummary = createMemo(() => { + if (!defaultModelInfo()) { + return { + id: "auto" as const, + label: "Unavailable", + description: "Connect to OpenCode to load answer styles for this assistant.", + rawValue: null, + }; + } + + return getModelStyleSummary(defaultModel(), providers(), modelVariant()); + }); + + const defaultAssistantHint = createMemo(() => { + if (!defaultModelInfo()) { + return "Connect to OpenCode to see model-specific defaults and answer styles here."; + } + + if (defaultAnswerStyleOptions().length > 0) { + return "A strong all-around choice. You can tune how thoughtful new runs feel."; + } + + if (defaultModelInfo()?.model.reasoning) { + return "This assistant has built-in reasoning, but it does not expose separate answer styles here."; + } + + return "A straightforward everyday assistant with a built-in response style."; + }); + function openSessionModelPicker() { setModelPickerTarget("session"); setModelPickerQuery(""); @@ -4795,16 +4809,41 @@ export default function App() { } function openDefaultModelPicker() { + setReopenAiDefaultsAfterModelPicker(false); + setModelPickerTarget("default"); + setModelPickerQuery(""); + setModelPickerOpen(true); + } + + function openAiDefaultsModal() { + setAiDefaultsModalOpen(true); + } + + function openDefaultModelPickerFromAiDefaults() { + setReopenAiDefaultsAfterModelPicker(true); + setAiDefaultsModalOpen(false); setModelPickerTarget("default"); setModelPickerQuery(""); setModelPickerOpen(true); } + function closeModelPicker() { + setModelPickerOpen(false); + if (!reopenAiDefaultsAfterModelPicker()) return; + setReopenAiDefaultsAfterModelPicker(false); + setAiDefaultsModalOpen(true); + } + function applyModelSelection(next: ModelRef) { if (modelPickerTarget() === "default") { setDefaultModelExplicit(true); setDefaultModel(next); + setModelVariant((current) => coerceModelVariantForModel(next, providers(), current)); setModelPickerOpen(false); + if (reopenAiDefaultsAfterModelPicker()) { + setReopenAiDefaultsAfterModelPicker(false); + setAiDefaultsModalOpen(true); + } return; } @@ -4830,6 +4869,7 @@ export default function App() { } function openSettingsFromModelPicker() { + setReopenAiDefaultsAfterModelPicker(false); setTab("settings"); setView("dashboard"); } @@ -6207,6 +6247,11 @@ export default function App() { } }); + createEffect(() => { + if (!providers().length) return; + setModelVariant((current) => coerceModelVariantForModel(defaultModel(), providers(), current)); + }); + createEffect(() => { if (typeof window === "undefined") return; try { @@ -6619,15 +6664,15 @@ export default function App() { selectSession: selectSession, defaultModelLabel: formatModelLabel(defaultModel(), providers()), defaultModelRef: formatModelRef(defaultModel()), - openDefaultModelPicker, + openAiDefaultsModal, showThinking: showThinking(), toggleShowThinking: () => setShowThinking((v) => !v), autoCompactContext: autoCompactContext(), toggleAutoCompactContext: () => setAutoCompactContext((v) => !v), hideTitlebar: hideTitlebar(), toggleHideTitlebar: () => setHideTitlebar((v) => !v), - modelVariantLabel: formatModelVariantLabel(modelVariant()), - editModelVariant: handleEditModelVariant, + answerStyleLabel: defaultAnswerStyleSummary().label, + answerStyleHint: defaultAnswerStyleSummary().description, updateAutoCheck: updateAutoCheck(), toggleUpdateAutoCheck: () => setUpdateAutoCheck((v) => !v), updateAutoDownload: updateAutoDownload(), @@ -6775,7 +6820,7 @@ export default function App() { openSessionModelPicker: openSessionModelPicker, modelVariantLabel: formatModelVariantLabel(modelVariant()), modelVariant: modelVariant(), - setModelVariant: (value: string) => setModelVariant(value), + setModelVariant: (value: string | null) => setModelVariant(value), activePlugins: sidebarPluginList(), activePluginStatus: sidebarPluginStatus(), mcpServers: mcpServers(), @@ -7024,7 +7069,29 @@ export default function App() { current={modelPickerCurrent()} onSelect={applyModelSelection} onOpenSettings={openSettingsFromModelPicker} - onClose={() => setModelPickerOpen(false)} + onClose={closeModelPicker} + /> + + setModelVariant(value)} + onToggleShowThinking={() => setShowThinking((current) => !current)} + onToggleAutoCompactContext={() => setAutoCompactContext((current) => !current)} + onClose={() => setAiDefaultsModalOpen(false)} /> void; + onSelectAnswerStyle: (value: string | null) => void; + onToggleShowThinking: () => void; + onToggleAutoCompactContext: () => void; + onClose: () => void; +}; + +const optionActive = ( + option: ModelStyleOption, + selectedId: ModelStyleOption["id"], + selectedRawValue: string | null, +) => { + if (option.rawValue === null) return selectedRawValue === null; + if (selectedRawValue === option.rawValue) return true; + return selectedId === option.id; +}; + +export default function AiDefaultsModal(props: AiDefaultsModalProps) { + createEffect(() => { + if (!props.open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + props.onClose(); + }; + + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }); + + return ( + +
+
+
+
+
+
+ + Your AI defaults +
+
+

How should OpenWork help?

+

+ Pick your default assistant and how thoughtful you want new runs to feel. +

+
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+ Default assistant +
+
{props.defaultModelLabel}
+
{props.defaultModelRef}
+
{props.assistantHint}
+
+
+ + +
+
+ +
+
+
+ +
+
+
+ Answer style for {props.defaultModelLabel} +
+
{props.answerStyleLabel}
+
{props.answerStyleHint}
+
+ Styles vary by assistant. OpenWork maps each one to the closest mode this assistant supports. +
+
+
+ + 0} + fallback={ +
+ {props.answerStyleUnavailable + ? "Connect to OpenCode to load the answer styles this assistant supports." + : "This assistant does not expose separate answer styles right now, so OpenWork will use its built-in default."} +
+ } + > +
+ + {(option) => { + const active = () => + optionActive(option, props.answerStyleId, props.answerStyleRawValue); + + return ( + + ); + }} + +
+
+
+ +
+
More options
+ +
+
+
Show step-by-step reasoning
+
+ Helpful when you want extra visibility. {props.developerMode ? "Developer mode is on." : "Visible in Developer mode."} +
+
+ +
+ +
+
+
Clean up long chats automatically
+
+ Keeps longer runs tidy by compacting context after they finish. +
+
+ +
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index c2e504703..f3590a3c3 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -33,7 +33,7 @@ type ComposerProps = { onModelClick: () => void; modelVariantLabel: string; modelVariant: string | null; - onModelVariantChange: (value: string) => void; + onModelVariantChange: (value: string | null) => void; agentLabel: string; selectedAgent: string | null; agentPickerOpen: boolean; @@ -207,11 +207,15 @@ const MAX_RECENT_EMITS = 400; const DRAFT_FLUSH_DEBOUNCE_MS = 140; const MODEL_VARIANT_OPTIONS = [ - { value: "none", label: "None" }, + { value: "__auto__", label: "Recommended" }, + { value: "none", label: "Fastest" }, + { value: "minimal", label: "Quick" }, { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, + { value: "medium", label: "Balanced" }, { value: "high", label: "High" }, + { value: "thinking", label: "Thoughtful" }, { value: "xhigh", label: "X-High" }, + { value: "max", label: "Max" }, ]; const partsToText = (parts: ComposerPart[]) => @@ -481,7 +485,7 @@ export default function Composer(props: ComposerProps) { const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] }); const [variantMenuOpen, setVariantMenuOpen] = createSignal(false); const [showInboxUploadAction, setShowInboxUploadAction] = createSignal(false); - const activeVariant = createMemo(() => props.modelVariant ?? "none"); + const activeVariant = createMemo(() => props.modelVariant ?? "__auto__"); const attachmentsDisabled = createMemo(() => !props.attachmentsEnabled); const hasDraftContent = createMemo(() => draftText().trim().length > 0 || attachments().length > 0); @@ -1933,7 +1937,7 @@ export default function Composer(props: ComposerProps) { : "text-gray-11 hover:bg-gray-2/70" }`} onClick={() => { - props.onModelVariantChange(option.value); + props.onModelVariantChange(option.value === "__auto__" ? null : option.value); setVariantMenuOpen(false); }} > diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 89af893b3..fc4aad911 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -241,15 +241,15 @@ export type DashboardViewProps = { selectSession: (sessionId: string) => Promise | void; defaultModelLabel: string; defaultModelRef: string; - openDefaultModelPicker: () => void; + openAiDefaultsModal: () => void; showThinking: boolean; toggleShowThinking: () => void; autoCompactContext: boolean; toggleAutoCompactContext: () => void; hideTitlebar: boolean; toggleHideTitlebar: () => void; - modelVariantLabel: string; - editModelVariant: () => void; + answerStyleLabel: string; + answerStyleHint: string; language: Language; setLanguage: (value: Language) => void; updateAutoCheck: boolean; @@ -1367,15 +1367,15 @@ export default function DashboardView(props: DashboardViewProps) { isWindows={props.isWindows} defaultModelLabel={props.defaultModelLabel} defaultModelRef={props.defaultModelRef} - openDefaultModelPicker={props.openDefaultModelPicker} + openAiDefaultsModal={props.openAiDefaultsModal} showThinking={props.showThinking} toggleShowThinking={props.toggleShowThinking} autoCompactContext={props.autoCompactContext} toggleAutoCompactContext={props.toggleAutoCompactContext} hideTitlebar={props.hideTitlebar} toggleHideTitlebar={props.toggleHideTitlebar} - modelVariantLabel={props.modelVariantLabel} - editModelVariant={props.editModelVariant} + answerStyleLabel={props.answerStyleLabel} + answerStyleHint={props.answerStyleHint} language={props.language} setLanguage={props.setLanguage} updateAutoCheck={props.updateAutoCheck} diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 760e2e629..ff4c88991 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -222,7 +222,7 @@ export type SessionViewProps = { openSessionModelPicker: () => void; modelVariantLabel: string; modelVariant: string | null; - setModelVariant: (value: string) => void; + setModelVariant: (value: string | null) => void; activePermission: PendingPermission | null; showTryNotionPrompt: boolean; onTryNotionPrompt: () => void; @@ -328,11 +328,15 @@ const MAIN_THREAD_LAG_WARN_MS = 180; type CommandPaletteMode = "root" | "sessions" | "thinking"; const COMMAND_PALETTE_THINKING_OPTIONS = [ - { value: "none", label: "None", detail: "Fastest responses" }, + { value: "__auto__", label: "Recommended", detail: "Use the assistant's usual balance" }, + { value: "none", label: "Fastest", detail: "Use the least extra thinking" }, + { value: "minimal", label: "Quick", detail: "Keep replies light and fast" }, { value: "low", label: "Low", detail: "Light reasoning" }, - { value: "medium", label: "Medium", detail: "Balanced depth" }, + { value: "medium", label: "Balanced", detail: "Good default depth" }, { value: "high", label: "High", detail: "Deeper reasoning" }, + { value: "thinking", label: "Thoughtful", detail: "Use extended thinking mode" }, { value: "xhigh", label: "X-High", detail: "Maximum effort" }, + { value: "max", label: "Max", detail: "Provider maximum effort" }, ] as const; export default function SessionView(props: SessionViewProps) { @@ -3686,10 +3690,10 @@ export default function SessionView(props: SessionViewProps) { }); const commandPaletteThinkingItems = createMemo(() => { - const normalizedRaw = (props.modelVariant ?? "none").trim().toLowerCase(); + const normalizedRaw = (props.modelVariant ?? "__auto__").trim().toLowerCase(); const activeVariant = normalizedRaw === "balanced" || normalizedRaw === "balance" - ? "none" + ? "medium" : normalizedRaw; const query = commandPaletteQuery().trim().toLowerCase(); @@ -3702,7 +3706,7 @@ export default function SessionView(props: SessionViewProps) { detail: option.detail, meta: activeVariant === option.value ? "Current" : undefined, action: () => { - props.setModelVariant(option.value); + props.setModelVariant(option.value === "__auto__" ? null : option.value); closeCommandPalette(); setToastMessage(`Thinking set to ${option.label}.`); }, diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index 25e63083c..05e16483c 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -93,15 +93,15 @@ export type SettingsViewProps = { isWindows: boolean; defaultModelLabel: string; defaultModelRef: string; - openDefaultModelPicker: () => void; + openAiDefaultsModal: () => void; showThinking: boolean; toggleShowThinking: () => void; autoCompactContext: boolean; toggleAutoCompactContext: () => void; hideTitlebar: boolean; toggleHideTitlebar: () => void; - modelVariantLabel: string; - editModelVariant: () => void; + answerStyleLabel: string; + answerStyleHint: string; language: Language; setLanguage: (value: Language) => void; themeMode: "light" | "dark" | "system"; @@ -1265,70 +1265,63 @@ export default function SettingsView(props: SettingsViewProps) {
-
-
-
Model
-
Defaults + thinking controls for runs.
-
- -
-
-
{props.defaultModelLabel}
-
{props.defaultModelRef}
+
+
+
+
+ + AI defaults +
+
+
How should OpenWork help?
+
+ Choose your default assistant and the kind of answers you want OpenWork to lean toward. +
+
+
-
-
-
Thinking
-
Show thinking parts (Developer mode only).
+
+
+
+ Default assistant +
+
{props.defaultModelLabel}
+
{props.defaultModelRef}
- -
-
-
-
Auto context compaction
-
Automatically compact after a run completes.
+
+
Answer style
+
{props.answerStyleLabel}
+
{props.answerStyleHint}
-
-
-
-
Model variant
-
{props.modelVariantLabel}
+
+
+
+ Step-by-step reasoning +
+
{props.showThinking ? "On" : "Off"}
+
Visible when Developer mode is on.
+
+ +
+
+ Clean up long chats +
+
{props.autoCompactContext ? "On" : "Off"}
+
Automatically compacts context after runs finish.
-
diff --git a/packages/app/src/app/utils/model-style.ts b/packages/app/src/app/utils/model-style.ts new file mode 100644 index 000000000..54e256ceb --- /dev/null +++ b/packages/app/src/app/utils/model-style.ts @@ -0,0 +1,210 @@ +import type { ModelRef, ProviderListItem } from "../types"; + +export type ModelStyleOption = { + id: "auto" | "quick" | "balanced" | "deep" | "maximum"; + rawValue: string | null; + label: string; + description: string; +}; + +const VARIANT_ORDER = ["none", "minimal", "low", "medium", "high", "thinking", "xhigh", "max"] as const; + +const STYLE_COPY: Record> = { + auto: { + label: "Recommended", + description: "Let this assistant use its usual balance.", + }, + quick: { + label: "Quick", + description: "Faster replies for easier tasks.", + }, + balanced: { + label: "Balanced", + description: "A good default for most questions.", + }, + deep: { + label: "Deep", + description: "More time for harder questions.", + }, + maximum: { + label: "Maximum", + description: "The most thorough mode this assistant supports.", + }, +}; + +const VARIANT_LABELS: Record<(typeof VARIANT_ORDER)[number], string> = { + none: "Fastest", + minimal: "Quick", + low: "Light", + medium: "Balanced", + high: "Deep", + thinking: "Thoughtful", + xhigh: "Maximum", + max: "Maximum", +}; + +const VARIANT_DESCRIPTIONS: Record<(typeof VARIANT_ORDER)[number], string> = { + none: "Use the least extra thinking.", + minimal: "Keep replies fast and lightweight.", + low: "Add a little more thinking time.", + medium: "Use a balanced amount of thinking.", + high: "Spend more time working through harder tasks.", + thinking: "Use the assistant's extended thinking mode.", + xhigh: "Use the assistant's highest effort mode.", + max: "Use the assistant's highest effort mode.", +}; + +const titleCase = (value: string) => + value + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const variantToStyle = (value: string | null): ModelStyleOption["id"] => { + if (!value) return "auto"; + if (value === "medium") return "balanced"; + if (value === "high" || value === "thinking") return "deep"; + if (value === "xhigh" || value === "max") return "maximum"; + return "quick"; +}; + +const pickVariant = (available: string[], choices: string[]) => + choices.find((value) => available.includes(value)) ?? null; + +export const normalizeModelVariant = (value: string | null | undefined) => { + if (!value) return null; + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return null; + if (["auto", "default", "recommended"].includes(trimmed)) return null; + if (trimmed === "balance" || trimmed === "balanced") return "medium"; + return VARIANT_ORDER.includes(trimmed as (typeof VARIANT_ORDER)[number]) ? trimmed : null; +}; + +export const findProviderModelByRef = (modelRef: ModelRef, providers: ProviderListItem[]) => { + const provider = providers.find((item) => item.id === modelRef.providerID); + const model = provider?.models?.[modelRef.modelID]; + if (!provider || !model) return null; + return { provider, model }; +}; + +export const getAvailableModelVariants = (modelRef: ModelRef, providers: ProviderListItem[]) => { + const match = findProviderModelByRef(modelRef, providers); + const keys = Object.keys(match?.model?.variants ?? {}) + .map((value) => normalizeModelVariant(value)) + .filter((value): value is string => Boolean(value)); + + return [...new Set(keys)].sort((a, b) => { + const left = VARIANT_ORDER.indexOf(a as (typeof VARIANT_ORDER)[number]); + const right = VARIANT_ORDER.indexOf(b as (typeof VARIANT_ORDER)[number]); + return left - right; + }); +}; + +export const getModelStyleOptions = (modelRef: ModelRef, providers: ProviderListItem[]) => { + const available = getAvailableModelVariants(modelRef, providers); + if (!available.length) return [] as ModelStyleOption[]; + + const options: ModelStyleOption[] = [ + { + id: "auto", + rawValue: null, + ...STYLE_COPY.auto, + }, + ]; + + const variants: Array = [ + pickVariant(available, ["minimal", "low", "none"]), + pickVariant(available, ["medium", "low", "minimal", "none"]), + pickVariant(available, ["high", "thinking"]), + pickVariant(available, ["xhigh", "max"]), + ].map((rawValue, index) => { + if (!rawValue) return null; + const id = (["quick", "balanced", "deep", "maximum"] as const)[index]; + return { + id, + rawValue, + ...STYLE_COPY[id], + }; + }); + + for (const option of variants) { + if (!option) continue; + const duplicate = options.some( + (existing) => existing.id === option.id || existing.rawValue === option.rawValue, + ); + if (!duplicate) { + options.push(option); + } + } + + return options; +}; + +export const getModelStyleSummary = ( + modelRef: ModelRef, + providers: ProviderListItem[], + value: string | null | undefined, +) => { + const options = getModelStyleOptions(modelRef, providers); + if (!options.length) { + return { + id: "auto" as const, + label: "Built in", + description: "This assistant uses its own built-in answer style.", + rawValue: null, + }; + } + + const normalized = normalizeModelVariant(value); + if (!normalized) { + return { + id: "auto" as const, + ...STYLE_COPY.auto, + rawValue: null, + }; + } + + const exact = options.find((option) => option.rawValue === normalized); + if (exact) return exact; + + const byStyle = options.find((option) => option.id === variantToStyle(normalized)); + if (byStyle) { + return { + ...byStyle, + rawValue: normalized, + }; + } + + return { + id: variantToStyle(normalized), + label: formatModelVariantLabel(normalized), + description: VARIANT_DESCRIPTIONS[normalized as (typeof VARIANT_ORDER)[number]] ?? "Use this assistant-specific mode.", + rawValue: normalized, + }; +}; + +export const coerceModelVariantForModel = ( + modelRef: ModelRef, + providers: ProviderListItem[], + value: string | null | undefined, +) => { + const normalized = normalizeModelVariant(value); + if (!normalized) return null; + const available = getAvailableModelVariants(modelRef, providers); + if (!available.length || available.includes(normalized)) return normalized; + const options = getModelStyleOptions(modelRef, providers); + return options.find((option) => option.id === variantToStyle(normalized))?.rawValue ?? null; +}; + +export const formatModelVariantLabel = (value: string | null | undefined) => { + const normalized = normalizeModelVariant(value); + if (!normalized) return "Recommended"; + return VARIANT_LABELS[normalized as (typeof VARIANT_ORDER)[number]] ?? titleCase(normalized); +}; + +export const formatModelVariantDescription = (value: string | null | undefined) => { + const normalized = normalizeModelVariant(value); + if (!normalized) return STYLE_COPY.auto.description; + return VARIANT_DESCRIPTIONS[normalized as (typeof VARIANT_ORDER)[number]] ?? "Use this assistant-specific mode."; +};