From 871bbe831954f8243cd762affb5cab26f0e5dbef Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 27 Aug 2026 16:16:05 -0700 Subject: [PATCH] fix(auto_router): close the gaps a blast-radius run found in the prompt preview Five findings, all in surface this PR added, none of them a regression to an existing path. The dry-run gate had no test at either form. Deleting the whole block from both left all 613 tests green, so the mechanism the validate endpoint exists to serve could have been removed invisibly. One case per form now asserts the save is not attempted on a rejecting verdict, and one asserts it still is on a passing one. An Admin Viewer could read the built-in prompt through the GET but got 403 on the preview, because default-allow only covers safe methods and a POST needs listing. Both methods on this path are pure reads, so the path joins admin_viewer_routes. The preview normalized nothing while the write gate strips and caps at 2000, so an untrimmed preamble previewed with whitespace the router removes, and an over-long one previewed fine and then failed on save. Both now call one normalize_classification_prompt, the same shape the prompt builder already uses, and the hand-rolled negative-window branch gives way to the field's own bound. A failed preview fetch rendered as a permanent "Loading", so a denied role saw a spinner rather than an error. The state is now loading, error or ready. Also: the built-in branch of the edit modal's dropped list was dead, since the key is managed and filtered earlier, and its comment described the wrong mechanism; and the prompt editor is renamed CustomTierPromptEditor so it is not one character from the built-in ClassifierPromptEditor beside it. --- litellm/proxy/_types.py | 4 + .../model_management_endpoints.py | 17 ++-- .../complexity_router/__init__.py | 2 + .../complexity_router/config.py | 25 ++++-- .../test_model_management_endpoints.py | 81 +++++++++++++++++++ .../add_model/ClassificationMethodConfig.tsx | 4 +- ...st.tsx => CustomTierPromptEditor.test.tsx} | 6 +- ...tEditor.tsx => CustomTierPromptEditor.tsx} | 36 ++++++--- .../add_model/add_auto_router_tab.test.tsx | 37 ++++++++- .../edit_auto_router_modal.test.tsx | 31 +++++-- .../edit_auto_router_modal.tsx | 7 +- 11 files changed, 208 insertions(+), 42 deletions(-) rename ui/litellm-dashboard/src/components/add_model/{ClassificationPromptEditor.test.tsx => CustomTierPromptEditor.test.tsx} (96%) rename ui/litellm-dashboard/src/components/add_model/{ClassificationPromptEditor.tsx => CustomTierPromptEditor.tsx} (80%) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bb26350e1b1..5e1d55efa7c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -938,6 +938,10 @@ class LiteLLMRoutes(enum.Enum): # Model cost map maintenance views (read-only status / source). "/schedule/model_cost_map_reload/status", "/model/cost_map/source", + # Assembles a classifier prompt from the caller's own tier definitions and returns it. + # A POST only because the prompt must not ride in a URL; it reads nothing and writes + # nothing, so it belongs with the GET on the same path that default-allow already covers. + "/auto_router/classifier/default_prompt", ] # Spend tracking reads (/spend/logs, /spend/logs/ui, /spend/keys, # /spend/users, /spend/tags, /spend/calculate, /cost/estimate). Admin diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 7c9f19cc86f..5978aaa9e41 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -16,10 +16,10 @@ import json from collections.abc import Awaitable, Mapping, Sequence from json import JSONDecodeError from types import MappingProxyType -from typing import TYPE_CHECKING, Final, Literal, Protocol, cast +from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, cast from fastapi import APIRouter, Depends, Header, HTTPException, Request, status -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -84,6 +84,7 @@ from litellm.router_strategy.complexity_router import ( TierDefinition, classification_system_prompt, custom_tier_classification_prompt, + normalize_classification_prompt, ) from litellm.router_utils.auto_router_model_naming import ( STRATEGY_ROUTER_PARAM_FIELDS, @@ -2233,9 +2234,11 @@ class AutoRouterClassifierPromptPreviewRequest(BaseModel): """ tier_definitions: tuple[TierDefinition, ...] - context_window_size: int = DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE + context_window_size: Annotated[int, Field(ge=0)] = DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE classification_prompt: str | None = None + _normalize_prompt = field_validator("classification_prompt")(normalize_classification_prompt) + @router.post( "/auto_router/classifier/default_prompt", @@ -2257,14 +2260,6 @@ async def preview_auto_router_classifier_prompt( TierDefinition validation is what makes the built-in-criteria lookup total: it rejects a non-built-in name carrying no description. Payload validity stays the dry-run's job. """ - if request.context_window_size < 0: - raise ProxyException( - message="context_window_size must be non-negative", - type=ProxyErrorTypes.bad_request_error, - code=status.HTTP_400_BAD_REQUEST, - param="context_window_size", - ) - return AutoRouterClassifierDefaultPromptResponse( system_prompt=custom_tier_classification_prompt( request.tier_definitions, request.classification_prompt, request.context_window_size diff --git a/litellm/router_strategy/complexity_router/__init__.py b/litellm/router_strategy/complexity_router/__init__.py index 9121249abd6..6cec118c0a8 100644 --- a/litellm/router_strategy/complexity_router/__init__.py +++ b/litellm/router_strategy/complexity_router/__init__.py @@ -20,6 +20,7 @@ from litellm.router_strategy.complexity_router.config import ( ComplexityTier, ReminderMarkerPair, TierDefinition, + normalize_classification_prompt, ) __all__ = [ @@ -33,4 +34,5 @@ __all__ = [ "TierDefinition", "classification_system_prompt", "custom_tier_classification_prompt", + "normalize_classification_prompt", ] diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 2cc39f36db7..fc1773bfb87 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -99,6 +99,23 @@ MAX_TIER_DESCRIPTION_CHARS: Final[int] = 500 MAX_CLASSIFICATION_PROMPT_CHARS: Final[int] = 2000 +def normalize_classification_prompt(value: str | None) -> str | None: + """Strip, reject blank, and cap an operator-written classifier preamble. + + The single owner of the rule, so the dashboard's prompt preview normalizes exactly what the + write gate stores: previewing the raw value would render leading whitespace the router strips, + or an over-long prompt the write then rejects. + """ + if value is None: + return None + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be non-empty; omit the field instead") + if len(stripped) > MAX_CLASSIFICATION_PROMPT_CHARS: + raise ValueError(f"classification_prompt exceeds {MAX_CLASSIFICATION_PROMPT_CHARS} characters") + return stripped + + class TierDefinition(BaseModel): """An operator-defined tier: the name the LLM classifier must return and its rubric description.""" @@ -1012,7 +1029,7 @@ class ComplexityRouterConfig(BaseModel): ) return self - @field_validator("fallback_tier", "classification_prompt") + @field_validator("fallback_tier") @classmethod def _reject_blank_optional_text(cls, value: str | None) -> str | None: if value is None: @@ -1024,10 +1041,8 @@ class ComplexityRouterConfig(BaseModel): @field_validator("classification_prompt") @classmethod - def _cap_classification_prompt(cls, value: str | None) -> str | None: - if value is not None and len(value) > MAX_CLASSIFICATION_PROMPT_CHARS: - raise ValueError(f"classification_prompt exceeds {MAX_CLASSIFICATION_PROMPT_CHARS} characters") - return value + def _normalize_classification_prompt_field(cls, value: str | None) -> str | None: + return normalize_classification_prompt(value) @property def has_custom_tiers(self) -> bool: diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 6c2f134d28e..78bada31e88 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -4324,6 +4324,87 @@ class TestAutoRouterClassifierDefaultPrompt: assert "tier_definitions" not in inspect.signature(get_auto_router_classifier_default_prompt).parameters assert list(inspect.signature(preview_auto_router_classifier_prompt).parameters) == ["request"] + @pytest.mark.asyncio + async def test_the_preview_normalizes_the_prompt_the_same_way_the_write_gate_stores_it(self): + """The preview exists to show what the router sends, so it must apply the write gate's own + normalization: an untrimmed preamble previewed raw shows whitespace the router strips.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + AutoRouterClassifierPromptPreviewRequest, + preview_auto_router_classifier_prompt, + ) + from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig + + raw = " Route for a payments team. " + response = await preview_auto_router_classifier_prompt( + AutoRouterClassifierPromptPreviewRequest.model_validate( + { + "tier_definitions": [ + {"name": "TRIAGE", "description": "quick"}, + {"name": "AUDIT", "description": "deep"}, + ], + "classification_prompt": raw, + } + ) + ) + stored = ComplexityRouterConfig.model_validate( + { + "tiers": {"TRIAGE": ["a"], "AUDIT": ["b"]}, + "tier_definitions": [ + {"name": "TRIAGE", "description": "quick"}, + {"name": "AUDIT", "description": "deep"}, + ], + "fallback_tier": "TRIAGE", + "classifier_type": "llm", + "classifier_llm_config": {"model": "m", "timeout_ms": 1}, + "classification_prompt": raw, + } + ).classification_prompt + assert response.system_prompt.startswith(stored) + + @pytest.mark.asyncio + async def test_the_preview_refuses_a_prompt_the_write_gate_would_reject(self): + """Previewing an over-long prompt would let an operator compose one that looks fine and then + fails on save, which is the drift this endpoint exists to prevent.""" + from pydantic import ValidationError as PydanticValidationError + + from litellm.proxy.management_endpoints.model_management_endpoints import ( + AutoRouterClassifierPromptPreviewRequest, + ) + from litellm.router_strategy.complexity_router.config import MAX_CLASSIFICATION_PROMPT_CHARS + + definitions = [{"name": "TRIAGE", "description": "quick"}, {"name": "AUDIT", "description": "deep"}] + for bad in ("x" * (MAX_CLASSIFICATION_PROMPT_CHARS + 1), " "): + with pytest.raises(PydanticValidationError): + AutoRouterClassifierPromptPreviewRequest.model_validate( + {"tier_definitions": definitions, "classification_prompt": bad} + ) + + @pytest.mark.asyncio + async def test_a_negative_context_window_is_rejected_by_the_field_not_a_hand_rolled_branch(self): + from pydantic import ValidationError as PydanticValidationError + + from litellm.proxy.management_endpoints.model_management_endpoints import ( + AutoRouterClassifierPromptPreviewRequest, + ) + + with pytest.raises(PydanticValidationError): + AutoRouterClassifierPromptPreviewRequest.model_validate( + { + "tier_definitions": [ + {"name": "TRIAGE", "description": "quick"}, + {"name": "AUDIT", "description": "deep"}, + ], + "context_window_size": -1, + } + ) + + def test_the_prompt_preview_is_readable_by_an_admin_viewer_like_the_get_beside_it(self): + """Both methods on this path are pure reads, so a role that may call the GET must not be + refused the POST purely because default-allow only covers safe methods.""" + from litellm.proxy._types import LiteLLMRoutes + + assert "/auto_router/classifier/default_prompt" in LiteLLMRoutes.admin_viewer_routes.value + @pytest.mark.asyncio async def test_malformed_tier_definitions_are_rejected_rather_than_silently_ignored(self): """A definition the router could not build a bullet from must fail loudly here rather than diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index eee1bcbf7a9..7a4dee87027 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -10,7 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import React from "react"; import ClassifierPromptEditor from "./ClassifierPromptEditor"; -import ClassificationPromptEditor from "./ClassificationPromptEditor"; +import CustomTierPromptEditor from "./CustomTierPromptEditor"; import { Restricted, restrictedBy } from "./TierRestrictions"; import HeuristicScoringConfig from "./HeuristicScoringConfig"; import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults"; @@ -416,7 +416,7 @@ const ClassificationMethodConfig: React.FC = ({
Classifier Prompt {value.custom_tier_set ? ( - ({ getAutoRouterCustomTierPromptCall: vi.fn(), @@ -19,7 +19,7 @@ const tierRows = [ const renderEditor = (classificationPrompt?: string) => { const onChange = vi.fn(); renderWithProviders( - { ); }); -describe("ClassificationPromptEditor", () => { +describe("CustomTierPromptEditor", () => { it("shows the prompt the proxy assembled rather than one rebuilt in the browser", async () => { renderEditor(); fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationPromptEditor.tsx b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx similarity index 80% rename from ui/litellm-dashboard/src/components/add_model/ClassificationPromptEditor.tsx rename to ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx index 17d87fc3854..a60882a3de2 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationPromptEditor.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx @@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Textarea } from "@/components/ui/textarea"; import { TierRow, activeTierName } from "./tier_rows"; -interface ClassificationPromptEditorProps { +interface CustomTierPromptEditorProps { classificationPrompt: string | undefined; onChange: (classificationPrompt: string | undefined) => void; tierRows: readonly TierRow[]; @@ -25,7 +25,7 @@ const wireDefinitions = (tierRows: readonly TierRow[]): { name: string; descript ...(row.definition.trim() && { description: row.definition.trim() }), })); -const ClassificationPromptEditor: React.FC = ({ +const CustomTierPromptEditor: React.FC = ({ classificationPrompt, onChange, tierRows, @@ -34,7 +34,9 @@ const ClassificationPromptEditor: React.FC = ({ const { accessToken } = useAuthorized(); const [isOpen, setIsOpen] = useState(false); const [draft, setDraft] = useState(""); - const [preview, setPreview] = useState(null); + const [preview, setPreview] = useState< + { status: "loading" } | { status: "error" } | { status: "ready"; text: string } + >({ status: "loading" }); const isOverridden = Boolean(classificationPrompt?.trim()); // Debounced so the preview follows the draft without a request per keystroke. Nothing is saved @@ -42,11 +44,17 @@ const ClassificationPromptEditor: React.FC = ({ const refreshPreview = useCallback(async () => { if (!accessToken) return; try { - setPreview( - await getAutoRouterCustomTierPromptCall(accessToken, contextWindowSize, wireDefinitions(tierRows), draft), + const text = await getAutoRouterCustomTierPromptCall( + accessToken, + contextWindowSize, + wireDefinitions(tierRows), + draft, ); + setPreview({ status: "ready", text }); } catch { - setPreview(null); + // Distinct from loading: a role that may not call the preview, or a prompt the write gate + // would reject, otherwise leaves the panel claiming it is still fetching, forever. + setPreview({ status: "error" }); } }, [accessToken, contextWindowSize, tierRows, draft]); @@ -58,7 +66,7 @@ const ClassificationPromptEditor: React.FC = ({ const openEditor = () => { setDraft(classificationPrompt ?? ""); - setPreview(null); + setPreview({ status: "loading" }); setIsOpen(true); }; @@ -108,14 +116,20 @@ const ClassificationPromptEditor: React.FC = ({

What this router sends

- {preview === null ? ( + {preview.status === "loading" && (

Loading the assembled prompt…

- ) : ( + )} + {preview.status === "error" && ( +

+ Could not load the assembled prompt. Your text is still saved as written. +

+ )} + {preview.status === "ready" && (
-                {preview}
+                {preview.text}
               
)}
@@ -134,4 +148,4 @@ const ClassificationPromptEditor: React.FC = ({ ); }; -export default ClassificationPromptEditor; +export default CustomTierPromptEditor; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index 374b7ce60c3..c84464e2d77 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -70,10 +70,14 @@ const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(() mockFetchAllModelDeployments: vi.fn(), })); +const { validateAutoRouterConfig } = vi.hoisted(() => ({ + validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }), +})); + vi.mock("../networking", () => ({ modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), testAutoRouterRouting: vi.fn(), - validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }), + validateAutoRouterConfig, })); vi.mock("@/components/llm_calls/fetch_models", () => ({ @@ -190,6 +194,37 @@ describe("AddAutoRouterTab", () => { expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" }); }); + // The dry-run exists so a config the write gate would refuse shows the backend's own message + // inline instead of coming back as a raw 400. Nothing asserted that its verdict actually stops + // the submit, so the whole gate could be deleted with the suite still green. + it("does not submit when the backend's dry-run rejects the config", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + validateAutoRouterConfig.mockResolvedValueOnce({ + valid: false, + error: "session_affinity cannot be combined with tier_definitions", + }); + + renderWithProviders(); + await user.type(screen.getByPlaceholderText(/smart_router/i), "rejected-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(validateAutoRouterConfig).toHaveBeenCalled()); + expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled(); + }); + + it("submits when the dry-run passes, so the gate is not simply blocking everything", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + validateAutoRouterConfig.mockResolvedValueOnce({ valid: true }); + + renderWithProviders(); + await user.type(screen.getByPlaceholderText(/smart_router/i), "accepted-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + }); + // LIT-5133: "Add keyword rule" seeds a row with no keywords, and the semantic toggle that used // to be the only thing checking them is off by default. The row was dropped on the way to the // payload, so the create succeeded and the caller's rule was gone with nothing said about it. diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index 01a734beb68..022dd0b7cad 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -10,17 +10,19 @@ vi.mock( async () => await import("../../../tests/mocks/complexityScorerDefaults"), ); -const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall } = vi.hoisted(() => ({ - modelPatchUpdateCall: vi.fn().mockResolvedValue({}), - modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), - getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."), -})); +const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall, validateAutoRouterConfig } = + vi.hoisted(() => ({ + validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }), + modelPatchUpdateCall: vi.fn().mockResolvedValue({}), + modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), + getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."), + })); vi.mock("../networking", () => ({ modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall, - validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }), + validateAutoRouterConfig, })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) })); @@ -97,6 +99,23 @@ describe("EditAutoRouterModal keyword matching", () => { expect(config.match_threshold).toBe(0.72); }); + // Same gate as the create form: the dry-run's verdict has to stop the PATCH, or an operator sees + // a raw 400 instead of the inline message the dry-run was added to give them. + it("does not PATCH when the backend's dry-run rejects the config", async () => { + const user = userEvent.setup(); + validateAutoRouterConfig.mockResolvedValueOnce({ + valid: false, + error: "tier_labels cannot be combined with tier_definitions", + }); + + renderModal(); + await screen.findByText(/Escalation Keywords/i); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(validateAutoRouterConfig).toHaveBeenCalled()); + expect(modelPatchUpdateCall).not.toHaveBeenCalled(); + }); + // The create form blocks this; the edit modal renders the same controls, so it must block it // too. The backend raises on semantic_keyword_matching without an embedding model or keyword // rules, so skipping the guard turns a friendly inline message into a raw 400. diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index fe0f6f4baba..36704a6d490 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -264,9 +264,10 @@ export const buildUpdatedComplexityRouterConfig = ( if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true; return customTechnicalKeywords !== undefined && key === "custom_technical_keywords"; }; - // A custom save drops the stored keys an edited tier set forbids; a built-in save drops a stored - // classification_prompt, which the backend accepts only beside tier_definitions. - const dropped: readonly string[] = value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : ["classification_prompt"]; + // A custom save drops the stored keys an edited tier set forbids. classification_prompt needs no + // entry here: it is a managed key, so a built-in save already drops it through isManaged and the + // built-in branch of the builder never re-emits it. + const dropped: readonly string[] = value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : []; const preservedConfig = Object.fromEntries( Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key) && !dropped.includes(key)), );