From 975a5c354f3f97b074028300b95ce1b1e0e2ef9b Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:40:15 +0100 Subject: [PATCH] Studio: Refine chat preset and group built-in presets (#5159) * UX: Refine chat preset and group built-in presets * fix: reuse built-in preset names and unify GGUF state reads * fix: built-in chat preset save and refresh behavior * Add chat preset invariant tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: decouple chat presets from model-specific settings Limit chat preset compare/apply/save behavior to temperature, topP, topK, minP, repetitionPenalty, presencePenalty, maxTokens, and systemPrompt. Preserve legacy stored preset data on load for backwards compatibility, but stop treating model-specific settings such as checkpoint, trustRemoteCode, and maxSeqLength as part of preset identity. Also align legacy prompt migration dedupe with the new preset semantics and add invariant coverage for preset-owned config comparisons. * fix: detect built-in preset edits from param changes * fix: correct built-in preset dirty state and speculative select values * fix: preserve default preset sync and keep qwen think pristine --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../frontend/src/features/chat/chat-page.tsx | 4 - .../src/features/chat/chat-settings-sheet.tsx | 738 ++++++++---------- .../chat/hooks/use-chat-model-runtime.ts | 111 +-- .../features/chat/presets/preset-policy.ts | 351 +++++++++ .../chat/stores/chat-runtime-store.ts | 29 + .../test_chat_preset_builtin_invariants.py | 272 +++++++ 6 files changed, 1040 insertions(+), 465 deletions(-) create mode 100644 studio/frontend/src/features/chat/presets/preset-policy.ts create mode 100644 tests/studio/test_chat_preset_builtin_invariants.py diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index e3239f7cc1..39683661c7 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -484,10 +484,6 @@ export function ChatPage(): ReactElement { const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen); const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen); - useEffect(() => { - return () => setSettingsOpen(false); - }, [setSettingsOpen]); - useEffect(() => { const threadId = search.thread; if (!threadId) return; diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index fc5f097969..08c5ef4080 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; @@ -64,54 +65,36 @@ import { } from "@/components/ui/tooltip"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { AnimatePresence, motion } from "motion/react"; -import type { ReactNode } from "react"; +import { Fragment, type ReactNode } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { - DEFAULT_INFERENCE_PARAMS, - type InferenceParams, -} from "./types/runtime"; + applyPresetParams, + BUILTIN_PRESET_NAMES, + BUILTIN_PRESETS, + defaultInferenceParams, + getBuiltinVariantName, + getOrderedPresets, + getPresetOwnedConfigKey, + getPresetSaveState, + getPresetSource, + getUniquePresetName, + isSamePresetConfig, + normalizeCustomPresets, + toPresetParams, + type Preset, +} from "./presets/preset-policy"; +import type { InferenceParams } from "./types/runtime"; -export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS; +export { defaultInferenceParams, type Preset } from "./presets/preset-policy"; export type { InferenceParams } from "./types/runtime"; -export interface Preset { - name: string; - params: InferenceParams; -} - interface LegacySystemPromptTemplate { name: string; content: string; } -const BUILTIN_PRESETS: Preset[] = [ - { name: "Default", params: { ...defaultInferenceParams } }, - { - name: "Creative", - params: { - ...defaultInferenceParams, - temperature: 1.5, - topP: 1.0, - topK: 0, - minP: 0.1, - repetitionPenalty: 1.0, - }, - }, - { - name: "Precise", - params: { - ...defaultInferenceParams, - temperature: 0.1, - topP: 0.95, - topK: 80, - minP: 0.01, - repetitionPenalty: 1.0, - }, - }, -]; - const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets"; const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts"; @@ -122,16 +105,13 @@ function canUseStorage(): boolean { return typeof window !== "undefined"; } -function getUniquePresetName(baseName: string, usedNames: Set): string { - const normalizedBase = baseName.trim() || "Imported Prompt"; - let nextName = normalizedBase; - let suffix = 2; - while (usedNames.has(nextName)) { - nextName = `${normalizedBase} ${suffix}`; - suffix += 1; +function saveCustomPresets(presets: Preset[]): void { + if (!canUseStorage()) return; + try { + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(presets)); + } catch { + // ignore } - usedNames.add(nextName); - return nextName; } function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { @@ -161,18 +141,7 @@ function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { ]); const seenImportedConfigKeys = new Set( [...BUILTIN_PRESETS, ...presets].map((preset) => - JSON.stringify({ - temperature: preset.params.temperature, - topP: preset.params.topP, - topK: preset.params.topK, - minP: preset.params.minP, - repetitionPenalty: preset.params.repetitionPenalty, - presencePenalty: preset.params.presencePenalty, - maxSeqLength: preset.params.maxSeqLength, - maxTokens: preset.params.maxTokens, - systemPrompt: preset.params.systemPrompt, - trustRemoteCode: preset.params.trustRemoteCode ?? false, - }), + getPresetOwnedConfigKey(preset.params), ), ); const importedPresets = parsed @@ -191,18 +160,7 @@ function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { }, })) .filter(({ importedParams }) => { - const configKey = JSON.stringify({ - temperature: importedParams.temperature, - topP: importedParams.topP, - topK: importedParams.topK, - minP: importedParams.minP, - repetitionPenalty: importedParams.repetitionPenalty, - presencePenalty: importedParams.presencePenalty, - maxSeqLength: importedParams.maxSeqLength, - maxTokens: importedParams.maxTokens, - systemPrompt: importedParams.systemPrompt, - trustRemoteCode: importedParams.trustRemoteCode ?? false, - }); + const configKey = getPresetOwnedConfigKey(importedParams); if (seenImportedConfigKeys.has(configKey)) return false; seenImportedConfigKeys.add(configKey); return true; @@ -216,8 +174,8 @@ function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); return presets; } - const mergedPresets = [...presets, ...importedPresets]; - localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(mergedPresets)); + const mergedPresets = normalizeCustomPresets([...presets, ...importedPresets]); + saveCustomPresets(mergedPresets); try { localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); @@ -255,7 +213,11 @@ function loadSavedCustomPresets(): Preset[] { }, })) .filter((preset) => preset.name.length > 0); - return migrateLegacySystemPromptTemplates(presets); + const normalized = normalizeCustomPresets(presets); + if (JSON.stringify(normalized) !== JSON.stringify(presets)) { + saveCustomPresets(normalized); + } + return migrateLegacySystemPromptTemplates(normalized); } catch { return migrateLegacySystemPromptTemplates([]); } @@ -270,82 +232,6 @@ function loadSavedActivePreset(): string { } } -type PresetSaveMode = - | "disabled" - | "overwrite-active" - | "overwrite-other" - | "create"; - -interface PresetSaveState { - mode: PresetSaveMode; - canSubmit: boolean; - isSaveReady: boolean; - buttonLabel: string; - title: string; -} - -function isSamePresetConfig(a: InferenceParams, b: InferenceParams): boolean { - return ( - a.temperature === b.temperature && - a.topP === b.topP && - a.topK === b.topK && - a.minP === b.minP && - a.repetitionPenalty === b.repetitionPenalty && - a.presencePenalty === b.presencePenalty && - a.maxSeqLength === b.maxSeqLength && - a.maxTokens === b.maxTokens && - a.systemPrompt === b.systemPrompt && - (a.trustRemoteCode ?? false) === (b.trustRemoteCode ?? false) - ); -} - -function getPresetSaveState({ - rawName, - activePreset, - presets, - activePresetDirty, -}: { - rawName: string; - activePreset: string; - presets: Preset[]; - activePresetDirty: boolean; -}): PresetSaveState { - const trimmedName = rawName.trim(); - if (!trimmedName) { - return { - mode: "disabled", - canSubmit: false, - isSaveReady: false, - buttonLabel: "Save", - title: "Enter a preset name", - }; - } - - const matchingPreset = presets.find((preset) => preset.name === trimmedName); - if (matchingPreset) { - const isActiveMatch = matchingPreset.name === activePreset; - return { - mode: isActiveMatch ? "overwrite-active" : "overwrite-other", - canSubmit: !isActiveMatch || activePresetDirty, - isSaveReady: !isActiveMatch || activePresetDirty, - buttonLabel: isActiveMatch && !activePresetDirty ? "Saved" : "Overwrite", - title: isActiveMatch - ? activePresetDirty - ? "Save current settings to this preset" - : "No unsaved changes" - : `Overwrite preset "${trimmedName}"`, - }; - } - - return { - mode: "create", - canSubmit: true, - isSaveReady: true, - buttonLabel: "Save as New", - title: `Save current settings as "${trimmedName}"`, - }; -} - function ParamSlider({ label, value, @@ -518,6 +404,10 @@ export function ChatSettingsPanel({ const setCustomContextLength = useChatRuntimeStore( (s) => s.setCustomContextLength, ); + const setActivePresetSource = useChatRuntimeStore( + (s) => s.setActivePresetSource, + ); + const activePresetSource = useChatRuntimeStore((s) => s.activePresetSource); const ctxDisplayValue = customContextLength ?? ggufContextLength ?? ""; const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null; @@ -540,12 +430,9 @@ export function ChatSettingsPanel({ >(undefined); const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false); const [systemPromptDraft, setSystemPromptDraft] = useState(""); + const [activePresetBaseline, setActivePresetBaseline] = useState(params); const presets = useMemo(() => { - const overrides = new Set(customPresets.map((preset) => preset.name)); - return [ - ...BUILTIN_PRESETS.filter((preset) => !overrides.has(preset.name)), - ...customPresets, - ]; + return getOrderedPresets(customPresets); }, [customPresets]); const activePresetDefinition = useMemo( () => presets.find((preset) => preset.name === activePreset) ?? null, @@ -555,17 +442,23 @@ export function ChatSettingsPanel({ () => customPresets.find((preset) => preset.name === activePreset) ?? null, [activePreset, customPresets], ); - const activeBuiltinPreset = useMemo( - () => - BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null, - [activePreset], - ); - const activePresetDirty = useMemo( - () => - activePresetDefinition == null - ? false - : !isSamePresetConfig(activePresetDefinition.params, params), - [activePresetDefinition, params], + const hasUnsavedPresetChanges = useMemo( + () => { + if (activePresetDefinition == null) { + return false; + } + if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) { + if (activePresetDefinition.name === "Default") { + return activePresetSource === "modified"; + } + return ( + activePresetSource === "modified" || + !isSamePresetConfig(activePresetDefinition.params, params) + ); + } + return !isSamePresetConfig(activePresetDefinition.params, params); + }, + [activePresetDefinition, activePresetSource, params], ); const presetSaveState = useMemo( () => @@ -573,9 +466,9 @@ export function ChatSettingsPanel({ rawName: presetNameInput, activePreset, presets, - activePresetDirty, + hasUnsavedPresetChanges, }), - [activePreset, activePresetDirty, presetNameInput, presets], + [activePreset, hasUnsavedPresetChanges, presetNameInput, presets], ); const systemPromptEditorDirty = systemPromptDraft !== params.systemPrompt; const trustRemoteCodeMissing = @@ -584,27 +477,24 @@ export function ChatSettingsPanel({ !(params.trustRemoteCode ?? false); function set(key: K) { - return (v: InferenceParams[K]) => onParamsChange({ ...params, [key]: v }); + return (v: InferenceParams[K]) => { + const nextParams = { ...params, [key]: v }; + const nextSource = isSamePresetConfig(activePresetBaseline, nextParams) + ? getPresetSource(activePreset) + : "modified"; + setActivePresetSource(nextSource); + onParamsChange(nextParams); + }; } function applyPreset(name: string) { const p = presets.find((pr) => pr.name === name); if (p) { - if ( - modelRequiresTrustRemoteCode && - !(p.params.trustRemoteCode ?? false) - ) { - toast.warning("This configuration turns custom code off", { - description: - "The current model needs custom code enabled to load. Keep it on for this model.", - }); - return; - } onParamsChange({ - ...p.params, - checkpoint: params.checkpoint, + ...applyPresetParams(params, p.params), }); setActivePreset(name); + setActivePresetSource(getPresetSource(name)); if (canUseStorage()) { try { localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, name); @@ -621,27 +511,29 @@ export function ChatSettingsPanel({ toast.error("Enter a preset name"); return; } + const usedNames = new Set([ + ...BUILTIN_PRESET_NAMES, + ...customPresets.map((preset) => preset.name), + ]); + const saveName = BUILTIN_PRESET_NAMES.has(trimmed) + ? getBuiltinVariantName(trimmed, usedNames) + : trimmed; setCustomPresets((prev) => { - const next = prev.filter((p) => p.name !== trimmed); - const merged = [...next, { name: trimmed, params: { ...params } }]; - if (canUseStorage()) { - try { - localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(merged)); - } catch { - // ignore - } - } + const next = prev.filter((p) => p.name !== saveName); + const merged = [...next, { name: saveName, params: toPresetParams(params) }]; + saveCustomPresets(merged); return merged; }); if (canUseStorage()) { try { - localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, trimmed); + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, saveName); } catch { // ignore } } - setActivePreset(trimmed); - setPresetNameInput(trimmed); + setActivePreset(saveName); + setActivePresetSource("custom"); + setPresetNameInput(saveName); } function deletePreset(name: string) { @@ -651,41 +543,20 @@ export function ChatSettingsPanel({ if (!hasCustomPreset) { return; } - const builtinPreset = BUILTIN_PRESETS.find((preset) => preset.name === name); const fallbackPreset = - builtinPreset ?? - BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? - null; - if ( - activePreset === name && - fallbackPreset && - modelRequiresTrustRemoteCode && - !(fallbackPreset.params.trustRemoteCode ?? false) - ) { - toast.warning("Reset would turn custom code off", { - description: - "The current model needs custom code enabled to load. Keep it on for this model.", - }); - return; - } + BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null; setCustomPresets((prev) => { const next = prev.filter((preset) => preset.name !== name); - if (canUseStorage()) { - try { - localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next)); - } catch { - // ignore - } - } + saveCustomPresets(next); return next; }); if (activePreset === name) { if (fallbackPreset) { onParamsChange({ - ...fallbackPreset.params, - checkpoint: params.checkpoint, + ...applyPresetParams(params, fallbackPreset.params), }); setActivePreset(fallbackPreset.name); + setActivePresetSource("builtin-default"); if (canUseStorage()) { try { localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, fallbackPreset.name); @@ -708,8 +579,46 @@ export function ChatSettingsPanel({ } useEffect(() => { - if (presets.some((preset) => preset.name === activePreset)) return; + if (activePresetSource !== "modified") { + setActivePresetBaseline(params); + } + }, [activePresetSource, params]); + + useEffect(() => { + if (presets.some((preset) => preset.name === activePreset)) { + const expectedSource = getPresetSource(activePreset); + if (activePresetDefinition != null) { + if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) { + if (activePresetDefinition.name === "Default") { + if ( + activePresetSource !== "modified" && + activePresetSource !== expectedSource + ) { + setActivePresetSource(expectedSource); + } + return; + } + const matchesActivePreset = isSamePresetConfig( + activePresetDefinition.params, + params, + ); + const nextSource = matchesActivePreset ? expectedSource : "modified"; + if (activePresetSource !== nextSource) { + setActivePresetSource(nextSource); + } + return; + } + } + if ( + activePresetSource !== "modified" && + activePresetSource !== expectedSource + ) { + setActivePresetSource(expectedSource); + } + return; + } setActivePreset("Default"); + setActivePresetSource("builtin-default"); if (canUseStorage()) { try { localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); @@ -717,7 +626,14 @@ export function ChatSettingsPanel({ // ignore } } - }, [activePreset, presets]); + }, [ + activePreset, + activePresetDefinition, + activePresetSource, + params, + presets, + setActivePresetSource, + ]); useEffect(() => { setPresetNameInput(activePreset); @@ -741,6 +657,187 @@ export function ChatSettingsPanel({ return () => ro.disconnect(); }, [open]); + const modelSection = ( + +
+ {isGguf && ( + <> +
+
+ Context Length + { + const raw = e.target.value; + if (raw === "") { + setCustomContextLength(null); + return; + } + const v = Number.parseInt(raw, 10); + if (!Number.isNaN(v) && v >= 0) { + const maxCtx = ctxMaxValue ?? Number.POSITIVE_INFINITY; + const clamped = Math.min(v, maxCtx); + setCustomContextLength( + clamped === (ggufContextLength ?? 0) ? null : clamped, + ); + } + }} + /> +
+ { + setCustomContextLength( + v === (ggufContextLength ?? 0) ? null : v, + ); + }} + /> + {ggufMaxContextLength != null && + typeof ctxDisplayValue === "number" && + ctxDisplayValue > ggufMaxContextLength && ( +

+ Exceeds estimated VRAM capacity ( + {ggufMaxContextLength.toLocaleString()} tokens). The model + may use system RAM. +

+ )} +
+
+
+
KV Cache Dtype
+
+ Quantize KV cache to reduce VRAM. +
+
+
+ +
+
+ {!currentModelIsVision && ( +
+
+
+ Speculative Decoding +
+
+ Speed up generation with no VRAM cost. +
+
+
+ +
+
+ )} + {modelSettingsDirty && ( +
+ + +
+ )} + + )} + {!isGguf && params.checkpoint && ( + <> +
+
+
Enable custom code
+
+ Allow models with custom code (e.g. Nemotron). Only enable if + sure. +
+
+ +
+ {trustRemoteCodeMissing && ( + + + Keep custom code enabled for this model + + + This model requires custom code to load. You can edit the + toggle, but loading will stay blocked until it is turned back + on. + + + )} + + )} +
+
+ ); + const settingsContent = ( <>
@@ -834,13 +931,16 @@ export function ChatSettingsPanel({ : undefined } > - {presets.map((p) => ( - applyPreset(p.name)} - > - {p.name} - + {presets.map((p, index) => ( + + applyPreset(p.name)}> + {p.name} + + {index === BUILTIN_PRESETS.length - 1 && + presets.length > BUILTIN_PRESETS.length && ( + + )} + ))} @@ -874,9 +974,7 @@ export function ChatSettingsPanel({ className="h-8 w-full text-xs text-muted-foreground" title={ activeCustomPreset - ? activeBuiltinPreset - ? "Reset selected preset to built-in defaults" - : "Delete selected preset" + ? "Delete selected preset" : "No saved override to delete" } > @@ -918,188 +1016,6 @@ export function ChatSettingsPanel({ />
- -
- {isGguf && ( - <> -
-
- Context Length - { - const raw = e.target.value; - if (raw === "") { - setCustomContextLength(null); - return; - } - const v = Number.parseInt(raw, 10); - if (!Number.isNaN(v) && v >= 0) { - const maxCtx = - ctxMaxValue ?? Number.POSITIVE_INFINITY; - const clamped = Math.min(v, maxCtx); - setCustomContextLength( - clamped === (ggufContextLength ?? 0) - ? null - : clamped, - ); - } - }} - /> -
- { - setCustomContextLength( - v === (ggufContextLength ?? 0) ? null : v, - ); - }} - /> - {ggufMaxContextLength != null && - typeof ctxDisplayValue === "number" && - ctxDisplayValue > ggufMaxContextLength && ( -

- Exceeds estimated VRAM capacity ( - {ggufMaxContextLength.toLocaleString()} tokens). The - model may use system RAM. -

- )} -
-
-
-
KV Cache Dtype
-
- Quantize KV cache to reduce VRAM. -
-
-
- -
-
- {!currentModelIsVision && ( -
-
-
- Speculative Decoding -
-
- Speed up generation with no VRAM cost. -
-
-
- -
-
- )} - {modelSettingsDirty && ( -
- - -
- )} - - )} - {!isGguf && params.checkpoint && ( - <> -
-
-
Enable custom code
-
- Allow models with custom code (e.g. Nemotron). Only - enable if sure. -
-
- -
- {trustRemoteCodeMissing && ( - - - Keep custom code enabled for this model - - - This model requires custom code to load. You can edit the - toggle, but loading will stay blocked until it is turned - back on. - - - )} - - )} -
-
- + {modelSection} +
diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index e90cab73fa..cfac6dc8cf 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -17,6 +17,10 @@ import { } from "../api/chat-api"; import { formatEta, formatRate } from "../utils/format-transfer"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; +import { + mergeBackendRecommendedInference, + resolveLoadMaxSeqLength, +} from "../presets/preset-policy"; import type { InferenceStatusResponse, LoadModelResponse } from "../types/api"; import type { ChatLoraSummary, @@ -132,46 +136,10 @@ function toLoraSummary(lora: { }; } -function toFiniteNumber(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) { - return undefined; - } - return value; -} - function getTrustRemoteCodeRequiredMessage(modelName: string): string { return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`; } -function mergeRecommendedInference( - current: InferenceParams, - response: LoadModelResponse | InferenceStatusResponse, - modelId: string, -): InferenceParams { - const inference = response.inference; - // GGUF: use actual context length from GGUF metadata, fallback to 131072 - // Non-GGUF: 4096 - const defaultMaxTokens = response.is_gguf - ? (response.context_length ?? 131072) - : 4096; - return { - ...current, - checkpoint: modelId, - maxTokens: defaultMaxTokens, - temperature: - toFiniteNumber(inference?.temperature) ?? current.temperature, - topP: toFiniteNumber(inference?.top_p) ?? current.topP, - topK: toFiniteNumber(inference?.top_k) ?? current.topK, - minP: toFiniteNumber(inference?.min_p) ?? current.minP, - presencePenalty: - toFiniteNumber(inference?.presence_penalty) ?? current.presencePenalty, - trustRemoteCode: - typeof inference?.trust_remote_code === "boolean" - ? inference.trust_remote_code - : current.trustRemoteCode, - }; -} - export function useChatModelRuntime() { const params = useChatRuntimeStore((state) => state.params); const models = useChatRuntimeStore((state) => state.models); @@ -273,7 +241,12 @@ export function useChatModelRuntime() { speculative_type: statusRes.speculative_type, }; setParams( - mergeRecommendedInference(currentParams, statusRes, statusRes.active_model), + mergeBackendRecommendedInference({ + current: currentParams, + response: statusRes, + modelId: statusRes.active_model, + presetSource: useChatRuntimeStore.getState().activePresetSource, + }), ); } @@ -465,13 +438,25 @@ export function useChatModelRuntime() { previousWasUnloaded = true; } - const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength, speculativeType } = useChatRuntimeStore.getState(); - // GGUF: use custom context length, or 0 = model's native context - // Non-GGUF: use the Max Seq Length slider value - const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf"); - const effectiveMaxSeqLength = customContextLength != null - ? customContextLength - : (ggufVariant != null || isDirectGgufFile) ? (ggufContextLength ?? 0) : maxSeqLength; + const { + chatTemplateOverride, + kvCacheDtype, + customContextLength, + ggufContextLength, + speculativeType, + activePresetSource, + activeGgufVariant, + } = useChatRuntimeStore.getState(); + const effectiveMaxSeqLength = resolveLoadMaxSeqLength({ + modelId, + ggufVariant, + customContextLength, + ggufContextLength, + currentCheckpoint, + activeGgufVariant, + maxSeqLength, + presetSource: activePresetSource, + }); const loadResponse = await loadModel({ model_path: modelId, hf_token: hfToken, @@ -491,7 +476,12 @@ export function useChatModelRuntime() { const currentParams = useChatRuntimeStore.getState().params; setParams( - mergeRecommendedInference(currentParams, loadResponse, modelId), + mergeBackendRecommendedInference({ + current: currentParams, + response: loadResponse, + modelId, + presetSource: useChatRuntimeStore.getState().activePresetSource, + }), ); // Qwen3.5/3.6 small models (0.8B, 2B, 4B, 9B) disable thinking by default let reasoningDefault = loadResponse.supports_reasoning ?? false; @@ -545,12 +535,31 @@ export function useChatModelRuntime() { // Qwen3/3.5/3.6: apply thinking-mode-specific params after load if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) { const store = useChatRuntimeStore.getState(); - const mid = modelId.toLowerCase(); - const needsPresencePenalty = mid.includes("qwen3.5") || mid.includes("qwen3.6"); - const p = reasoningDefault - ? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) } - : { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }; - store.setParams({ ...store.params, ...p }); + if (store.activePresetSource === "builtin-default") { + const mid = modelId.toLowerCase(); + const needsPresencePenalty = + mid.includes("qwen3.5") || mid.includes("qwen3.6"); + const p = reasoningDefault + ? { + temperature: 0.6, + topP: 0.95, + topK: 20, + minP: 0.0, + ...(needsPresencePenalty + ? { presencePenalty: 1.5 } + : {}), + } + : { + temperature: 0.7, + topP: 0.8, + topK: 20, + minP: 0.0, + ...(needsPresencePenalty + ? { presencePenalty: 1.5 } + : {}), + }; + store.setParams({ ...store.params, ...p }); + } } await refresh(); } catch (error) { diff --git a/studio/frontend/src/features/chat/presets/preset-policy.ts b/studio/frontend/src/features/chat/presets/preset-policy.ts new file mode 100644 index 0000000000..8c5d2573c9 --- /dev/null +++ b/studio/frontend/src/features/chat/presets/preset-policy.ts @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + DEFAULT_INFERENCE_PARAMS, + type InferenceParams, +} from "../types/runtime"; + +export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS; + +export interface Preset { + name: string; + params: InferenceParams; +} + +export type PresetOwnedParams = Pick< + InferenceParams, + | "temperature" + | "topP" + | "topK" + | "minP" + | "repetitionPenalty" + | "presencePenalty" + | "maxTokens" + | "systemPrompt" +>; + +export const BUILTIN_PRESETS: Preset[] = [ + { name: "Default", params: { ...defaultInferenceParams } }, + { + name: "Creative", + params: { + ...defaultInferenceParams, + temperature: 1.5, + topP: 1.0, + topK: 0, + minP: 0.1, + repetitionPenalty: 1.0, + }, + }, + { + name: "Precise", + params: { + ...defaultInferenceParams, + temperature: 0.1, + topP: 0.95, + topK: 80, + minP: 0.01, + repetitionPenalty: 1.0, + }, + }, +]; + +export const BUILTIN_PRESET_NAMES = new Set( + BUILTIN_PRESETS.map((preset) => preset.name), +); + +export type ChatPresetSource = + | "builtin-default" + | "builtin-fixed" + | "custom" + | "modified"; + +export function getPresetSource(name: string): ChatPresetSource { + if (name === "Default") return "builtin-default"; + if (BUILTIN_PRESET_NAMES.has(name)) return "builtin-fixed"; + return "custom"; +} + +export function getUniquePresetName( + baseName: string, + usedNames: Set, +): string { + const normalizedBase = baseName.trim() || "Imported Prompt"; + let nextName = normalizedBase; + let suffix = 2; + while (usedNames.has(nextName)) { + nextName = `${normalizedBase} ${suffix}`; + suffix += 1; + } + usedNames.add(nextName); + return nextName; +} + +export function getBuiltinVariantName( + baseName: string, + usedNames: Set, +): string { + const normalizedBase = baseName.trim() || "Imported Prompt"; + let suffix = 1; + let nextName = `${normalizedBase} ${suffix}`; + while (usedNames.has(nextName)) { + suffix += 1; + nextName = `${normalizedBase} ${suffix}`; + } + usedNames.add(nextName); + return nextName; +} + +export function normalizeCustomPresets(presets: Preset[]): Preset[] { + const usedNames = new Set(BUILTIN_PRESET_NAMES); + return presets + .map((preset): Preset | null => { + const trimmedName = preset.name.trim(); + if (!trimmedName) return null; + const name = usedNames.has(trimmedName) + ? getBuiltinVariantName(trimmedName, usedNames) + : trimmedName; + usedNames.add(name); + return { + name, + params: preset.params, + }; + }) + .filter((preset): preset is Preset => preset !== null); +} + +export function getOrderedPresets(customPresets: Preset[]): Preset[] { + return [...BUILTIN_PRESETS, ...normalizeCustomPresets(customPresets)]; +} + +export function isSamePresetConfig( + a: InferenceParams, + b: InferenceParams, +): boolean { + const left = getPresetOwnedParams(a); + const right = getPresetOwnedParams(b); + return ( + left.temperature === right.temperature && + left.topP === right.topP && + left.topK === right.topK && + left.minP === right.minP && + left.repetitionPenalty === right.repetitionPenalty && + left.presencePenalty === right.presencePenalty && + left.maxTokens === right.maxTokens && + left.systemPrompt === right.systemPrompt + ); +} + +export function getPresetOwnedParams( + params: InferenceParams, +): PresetOwnedParams { + return { + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + minP: params.minP, + repetitionPenalty: params.repetitionPenalty, + presencePenalty: params.presencePenalty, + maxTokens: params.maxTokens, + systemPrompt: params.systemPrompt, + }; +} + +export function getPresetOwnedConfigKey(params: InferenceParams): string { + return JSON.stringify(getPresetOwnedParams(params)); +} + +export function toPresetParams(params: InferenceParams): InferenceParams { + return { + ...defaultInferenceParams, + ...getPresetOwnedParams(params), + }; +} + +export function applyPresetParams( + current: InferenceParams, + preset: InferenceParams, +): InferenceParams { + return { + ...current, + ...getPresetOwnedParams(preset), + }; +} + +export type PresetSaveMode = + | "disabled" + | "overwrite-active" + | "overwrite-other" + | "copy-builtin" + | "create"; + +export interface PresetSaveState { + mode: PresetSaveMode; + canSubmit: boolean; + isSaveReady: boolean; + buttonLabel: string; + title: string; +} + +export function getPresetSaveState({ + rawName, + activePreset, + presets, + hasUnsavedPresetChanges, +}: { + rawName: string; + activePreset: string; + presets: Preset[]; + hasUnsavedPresetChanges: boolean; +}): PresetSaveState { + const trimmedName = rawName.trim(); + if (!trimmedName) { + return { + mode: "disabled", + canSubmit: false, + isSaveReady: false, + buttonLabel: "Save", + title: "Enter a preset name", + }; + } + + if (BUILTIN_PRESET_NAMES.has(trimmedName)) { + const variantName = getBuiltinVariantName(trimmedName, new Set(presets.map((preset) => preset.name))); + return { + mode: "copy-builtin", + canSubmit: activePreset !== trimmedName || hasUnsavedPresetChanges, + isSaveReady: activePreset !== trimmedName || hasUnsavedPresetChanges, + buttonLabel: + activePreset === trimmedName && !hasUnsavedPresetChanges + ? "Saved" + : "Save", + title: + activePreset === trimmedName && !hasUnsavedPresetChanges + ? "No unsaved changes" + : `Save current settings as "${variantName}"`, + }; + } + + const matchingPreset = presets.find((preset) => preset.name === trimmedName); + if (matchingPreset) { + const isActiveMatch = matchingPreset.name === activePreset; + return { + mode: isActiveMatch ? "overwrite-active" : "overwrite-other", + canSubmit: !isActiveMatch || hasUnsavedPresetChanges, + isSaveReady: !isActiveMatch || hasUnsavedPresetChanges, + buttonLabel: + isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save", + title: isActiveMatch + ? hasUnsavedPresetChanges + ? "Save current settings to this preset" + : "No unsaved changes" + : `Overwrite preset "${trimmedName}"`, + }; + } + + return { + mode: "create", + canSubmit: true, + isSaveReady: true, + buttonLabel: "Save", + title: `Save current settings as "${trimmedName}"`, + }; +} + +function toFiniteNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return value; +} + +interface BackendInferenceDefaults { + temperature?: number; + top_p?: number; + top_k?: number; + min_p?: number; + presence_penalty?: number; + trust_remote_code?: boolean; +} + +export interface BackendInferenceEnvelope { + is_gguf?: boolean; + context_length?: number | null; + inference?: BackendInferenceDefaults; +} + +export function mergeBackendRecommendedInference({ + current, + response, + modelId, + presetSource, +}: { + current: InferenceParams; + response: BackendInferenceEnvelope; + modelId: string; + presetSource: ChatPresetSource; +}): InferenceParams { + const inference = response.inference; + const next: InferenceParams = { + ...current, + checkpoint: modelId, + trustRemoteCode: + typeof inference?.trust_remote_code === "boolean" + ? inference.trust_remote_code + : current.trustRemoteCode, + }; + + if (presetSource !== "builtin-default") { + return next; + } + + const defaultMaxTokens = response.is_gguf + ? (response.context_length ?? current.maxTokens) + : 4096; + return { + ...next, + maxTokens: defaultMaxTokens, + temperature: + toFiniteNumber(inference?.temperature) ?? defaultInferenceParams.temperature, + topP: toFiniteNumber(inference?.top_p) ?? defaultInferenceParams.topP, + topK: toFiniteNumber(inference?.top_k) ?? defaultInferenceParams.topK, + minP: toFiniteNumber(inference?.min_p) ?? defaultInferenceParams.minP, + presencePenalty: + toFiniteNumber(inference?.presence_penalty) ?? + defaultInferenceParams.presencePenalty, + }; +} + +export function resolveLoadMaxSeqLength({ + modelId, + ggufVariant, + customContextLength, + ggufContextLength, + currentCheckpoint, + activeGgufVariant, + maxSeqLength, + presetSource, +}: { + modelId: string; + ggufVariant?: string | null; + customContextLength: number | null; + ggufContextLength: number | null; + currentCheckpoint: string; + activeGgufVariant?: string | null; + maxSeqLength: number; + presetSource: ChatPresetSource; +}): number { + const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf"); + const isGgufLoad = ggufVariant != null || isDirectGgufFile; + const isReloadingCurrentGguf = + isGgufLoad && + currentCheckpoint === modelId && + (ggufVariant ?? null) === (activeGgufVariant ?? null); + + if (customContextLength != null) return customContextLength; + if (isGgufLoad && presetSource === "builtin-default") return 0; + if (isReloadingCurrentGguf) return ggufContextLength ?? 0; + if (isGgufLoad) return 0; + return maxSeqLength; +} diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index ce69d8f6dc..13f2a23c36 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -9,6 +9,10 @@ import { type ChatModelSummary, type InferenceParams, } from "../types/runtime"; +import { + getPresetSource, + type ChatPresetSource, +} from "../presets/preset-policy"; const AUTO_TITLE_KEY = "unsloth_chat_auto_title"; const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls"; @@ -16,6 +20,8 @@ const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message"; const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout"; const HF_TOKEN_KEY = "unsloth_hf_token"; const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params"; +const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; +const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source"; const REASONING_EFFORT_KEY = "unsloth_reasoning_effort"; const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking"; @@ -157,8 +163,24 @@ function saveInferenceParams(params: InferenceParams): boolean { } } +function loadPresetSource(): ChatPresetSource { + const activePreset = loadString(CHAT_ACTIVE_PRESET_KEY, "Default"); + if (canUseStorage()) { + try { + const raw = localStorage.getItem(CHAT_ACTIVE_PRESET_SOURCE_KEY); + if (raw === "modified") { + return "modified"; + } + } catch { + // ignore + } + } + return getPresetSource(activePreset); +} + type ChatRuntimeStore = { params: InferenceParams; + activePresetSource: ChatPresetSource; models: ChatModelSummary[]; loras: ChatLoraSummary[]; runningByThreadId: Record; @@ -207,6 +229,7 @@ type ChatRuntimeStore = { setModelLoading: (loading: boolean) => void; setModelRequiresTrustRemoteCode: (required: boolean) => void; setParams: (params: InferenceParams) => void; + setActivePresetSource: (source: ChatPresetSource) => void; setModels: (models: ChatModelSummary[]) => void; setLoras: (loras: ChatLoraSummary[]) => void; setThreadRunning: (threadId: string, running: boolean) => void; @@ -241,6 +264,7 @@ type ChatRuntimeStore = { export const useChatRuntimeStore = create((set) => ({ params: loadInferenceParams(), + activePresetSource: loadPresetSource(), models: [], loras: [], runningByThreadId: {}, @@ -296,6 +320,11 @@ export const useChatRuntimeStore = create((set) => ({ } return { params }; }), + setActivePresetSource: (activePresetSource) => + set(() => { + saveString(CHAT_ACTIVE_PRESET_SOURCE_KEY, activePresetSource); + return { activePresetSource }; + }), setModels: (models) => set({ models }), setLoras: (loras) => set({ loras }), setThreadRunning: (threadId, running) => diff --git a/tests/studio/test_chat_preset_builtin_invariants.py b/tests/studio/test_chat_preset_builtin_invariants.py new file mode 100644 index 0000000000..da5f21099e --- /dev/null +++ b/tests/studio/test_chat_preset_builtin_invariants.py @@ -0,0 +1,272 @@ +import json +import os +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +WORKDIR = Path(__file__).resolve().parents[2] +PRESET_POLICY = ( + WORKDIR / "unsloth_repo/studio/frontend/src/features/chat/presets/preset-policy.ts" +) +RUNTIME_TYPES = ( + WORKDIR / "unsloth_repo/studio/frontend/src/features/chat/types/runtime.ts" +) +TEMP = WORKDIR / "temp" / "chat_preset_builtin_invariants" + + +def _require_node(): + if shutil.which("node") is None: + pytest.skip("node not available") + if not PRESET_POLICY.exists() or not RUNTIME_TYPES.exists(): + pytest.skip("studio chat sources not present") + + +def _ensure_harness(): + TEMP.mkdir(parents = True, exist_ok = True) + (TEMP / "register.mjs").write_text( + "import { register } from 'node:module';\n" + "register('./loader.mjs', import.meta.url);\n" + ) + (TEMP / "loader.mjs").write_text( + "export function resolve(specifier, context, next) {\n" + " if (specifier.endsWith('/types/runtime')) return next(specifier + '.ts', context);\n" + " return next(specifier, context);\n" + "}\n" + ) + + +def _run(script: str): + _require_node() + _ensure_harness() + script_path = TEMP / "run.mts" + script_path.write_text(script) + env = dict(os.environ, NODE_NO_WARNINGS = "1") + result = subprocess.run( + [ + "node", + "--experimental-strip-types", + "--import=./register.mjs", + "--no-warnings", + "run.mts", + ], + cwd = str(TEMP), + capture_output = True, + text = True, + timeout = 30, + env = env, + ) + assert result.returncode == 0, f"stderr: {result.stderr}\nstdout: {result.stdout}" + last = [line for line in result.stdout.strip().splitlines() if line.strip()][-1] + return json.loads(last) + + +def _policy_path(): + return os.path.relpath(PRESET_POLICY, TEMP).replace("\\", "/") + + +def _runtime_path(): + return os.path.relpath(RUNTIME_TYPES, TEMP).replace("\\", "/") + + +def test_default_builtin_matches_default_inference_params(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + import {{ DEFAULT_INFERENCE_PARAMS }} from "{_runtime_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + console.log(JSON.stringify({{ + found: !!def, + matches: def ? isSamePresetConfig(def.params, DEFAULT_INFERENCE_PARAMS) : null, + }})); + """ + ) + ) + assert out["found"] is True + assert out["matches"] is True + + +def test_is_same_preset_config_detects_temperature_edit(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const edited = {{ ...def.params, temperature: def.params.temperature + 0.1 }}; + console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }})); + """ + ) + ) + assert out["same"] is False + + +def test_is_same_preset_config_detects_system_prompt_edit(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const edited = {{ ...def.params, systemPrompt: "you are a pirate" }}; + console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }})); + """ + ) + ) + assert out["same"] is False + + +def test_is_same_preset_config_ignores_checkpoint_difference(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const withCheckpoint = {{ ...def.params, checkpoint: "meta-llama/Llama-3-8B" }}; + console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, withCheckpoint) }})); + """ + ) + ) + assert out["same"] is True + + +def test_is_same_preset_config_ignores_model_owned_fields(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const edited = {{ + ...def.params, + maxSeqLength: def.params.maxSeqLength + 1024, + trustRemoteCode: !def.params.trustRemoteCode, + }}; + console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }})); + """ + ) + ) + assert out["same"] is True + + +def test_preset_owned_config_key_ignores_model_owned_fields(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, getPresetOwnedConfigKey }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const edited = {{ + ...def.params, + checkpoint: "foo/bar", + maxSeqLength: def.params.maxSeqLength + 1024, + trustRemoteCode: !def.params.trustRemoteCode, + }}; + console.log(JSON.stringify({{ + same: getPresetOwnedConfigKey(def.params) === getPresetOwnedConfigKey(edited), + }})); + """ + ) + ) + assert out["same"] is True + + +def test_to_preset_params_strips_model_owned_fields(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ toPresetParams }} from "{_policy_path()}"; + const sanitized = toPresetParams({{ + temperature: 0.9, + topP: 0.8, + topK: 40, + minP: 0.05, + repetitionPenalty: 1.1, + presencePenalty: 0.4, + maxSeqLength: 16384, + maxTokens: 2048, + systemPrompt: "hello", + checkpoint: "foo/bar", + trustRemoteCode: true, + }}); + console.log(JSON.stringify({{ + checkpoint: sanitized.checkpoint, + trustRemoteCode: sanitized.trustRemoteCode, + maxSeqLength: sanitized.maxSeqLength, + maxTokens: sanitized.maxTokens, + systemPrompt: sanitized.systemPrompt, + }})); + """ + ) + ) + assert out["checkpoint"] == "" + assert out["trustRemoteCode"] is False + assert out["maxSeqLength"] == 4096 + assert out["maxTokens"] == 2048 + assert out["systemPrompt"] == "hello" + + +def test_apply_preset_params_preserves_model_owned_fields(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, applyPresetParams }} from "{_policy_path()}"; + const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative"); + const applied = applyPresetParams( + {{ + temperature: 0.6, + topP: 0.95, + topK: 20, + minP: 0.01, + repetitionPenalty: 1.0, + presencePenalty: 0.0, + maxSeqLength: 16384, + maxTokens: 8192, + systemPrompt: "keep me?", + checkpoint: "foo/bar", + trustRemoteCode: true, + }}, + creative.params, + ); + console.log(JSON.stringify({{ + checkpoint: applied.checkpoint, + trustRemoteCode: applied.trustRemoteCode, + maxSeqLength: applied.maxSeqLength, + temperature: applied.temperature, + topK: applied.topK, + }})); + """ + ) + ) + assert out["checkpoint"] == "foo/bar" + assert out["trustRemoteCode"] is True + assert out["maxSeqLength"] == 16384 + assert out["temperature"] == 1.5 + assert out["topK"] == 0 + + +def test_creative_and_precise_builtins_differ_from_default(): + out = _run( + textwrap.dedent( + f""" + // @ts-nocheck + import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}"; + const def = BUILTIN_PRESETS.find((p) => p.name === "Default"); + const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative"); + const precise = BUILTIN_PRESETS.find((p) => p.name === "Precise"); + console.log(JSON.stringify({{ + creativeDiffers: !isSamePresetConfig(def.params, creative.params), + preciseDiffers: !isSamePresetConfig(def.params, precise.params), + }})); + """ + ) + ) + assert out["creativeDiffers"] is True + assert out["preciseDiffers"] is True