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.
This commit is contained in:
Tin Chi Lo 2026-08-27 16:16:05 -07:00
parent 5c6d2a8627
commit 871bbe8319
11 changed files with 208 additions and 42 deletions

View file

@ -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

View file

@ -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

View file

@ -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",
]

View file

@ -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:

View file

@ -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

View file

@ -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<ClassificationMethodConfigProps> = ({
<div>
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
{value.custom_tier_set ? (
<ClassificationPromptEditor
<CustomTierPromptEditor
classificationPrompt={value.classification_prompt}
onChange={handleClassificationPromptChange}
tierRows={value.custom_tier_set.tiers}

View file

@ -1,6 +1,6 @@
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import { vi } from "vitest";
import ClassificationPromptEditor from "./ClassificationPromptEditor";
import CustomTierPromptEditor from "./CustomTierPromptEditor";
const { getAutoRouterCustomTierPromptCall } = vi.hoisted(() => ({
getAutoRouterCustomTierPromptCall: vi.fn(),
@ -19,7 +19,7 @@ const tierRows = [
const renderEditor = (classificationPrompt?: string) => {
const onChange = vi.fn();
renderWithProviders(
<ClassificationPromptEditor
<CustomTierPromptEditor
classificationPrompt={classificationPrompt}
onChange={onChange}
tierRows={tierRows}
@ -36,7 +36,7 @@ beforeEach(() => {
);
});
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" }));

View file

@ -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<ClassificationPromptEditorProps> = ({
const CustomTierPromptEditor: React.FC<CustomTierPromptEditorProps> = ({
classificationPrompt,
onChange,
tierRows,
@ -34,7 +34,9 @@ const ClassificationPromptEditor: React.FC<ClassificationPromptEditorProps> = ({
const { accessToken } = useAuthorized();
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState("");
const [preview, setPreview] = useState<string | null>(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<ClassificationPromptEditorProps> = ({
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<ClassificationPromptEditorProps> = ({
const openEditor = () => {
setDraft(classificationPrompt ?? "");
setPreview(null);
setPreview({ status: "loading" });
setIsOpen(true);
};
@ -108,14 +116,20 @@ const ClassificationPromptEditor: React.FC<ClassificationPromptEditorProps> = ({
<div className="mt-3">
<p className="text-xs font-medium">What this router sends</p>
{preview === null ? (
{preview.status === "loading" && (
<p className="mt-1 text-xs text-muted-foreground">Loading the assembled prompt…</p>
) : (
)}
{preview.status === "error" && (
<p className="mt-1 text-xs text-muted-foreground">
Could not load the assembled prompt. Your text is still saved as written.
</p>
)}
{preview.status === "ready" && (
<pre
aria-label="Assembled classifier prompt"
className="mt-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground"
>
{preview}
{preview.text}
</pre>
)}
</div>
@ -134,4 +148,4 @@ const ClassificationPromptEditor: React.FC<ClassificationPromptEditorProps> = ({
);
};
export default ClassificationPromptEditor;
export default CustomTierPromptEditor;

View file

@ -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(<Harness />);
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(<Harness />);
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.

View file

@ -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.

View file

@ -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)),
);