mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
5c6d2a8627
commit
871bbe8319
11 changed files with 208 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue