Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@
autoRouterCompression = DEFAULT_AUTO_ROUTER_COMPRESSION,
onAutoRouterCompressionChange,
showValidationErrors = false,
}) => {

Check warning on line 560 in ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 29. Maximum allowed is 20
const forecast = isForecastClassifier(value.classifier_type);
const customTierSet = value.custom_tier_set;
const tierRows = activeTierRows(value);
Expand Down Expand Up @@ -794,19 +794,15 @@
},
]
: []),
...(value.classifier_type !== "llm_v2"
? [
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: (
<Restricted by={restrictedBy(value, "adaptive")}>
<AdaptiveRoutingConfig value={value} onChange={onChange} />
</Restricted>
),
},
]
: []),
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: (
<Restricted by={restrictedBy(value, "adaptive")}>
<AdaptiveRoutingConfig value={value} onChange={onChange} />
</Restricted>
),
},
{
key: "affinity",
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
Expand Down Expand Up @@ -906,15 +902,17 @@
},
]
: []),
].map(({ key, label, children }) => (
<Collapsible key={key} className="border-b border-border last:border-b-0">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{label}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
))}
]
.filter(({ key }) => !forecast || !["adaptive", "context-window", "escalation"].includes(key))
.map(({ key, label, children }) => (
<Collapsible key={key} className="border-b border-border last:border-b-0">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{label}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
))}
</div>
</RoutingOptions>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("forecast classifier form", () => {
expect(output).toHaveTextContent('"REASONING":["capable"]');
expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024');
expect(output).toHaveTextContent('"reasoning_effort":"high"');
expect(output).toHaveTextContent('"adaptive":true');
expect(output).toHaveTextContent('"adaptive":false');
expect(output).not.toHaveTextContent("leftover-medium");
expect(output).not.toHaveTextContent("leftover-complex");
expect(output).not.toHaveTextContent('"plan_mode_min_tier"');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,24 @@
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
if (capability) expect(screen.getByText("Advanced: Adaptive Routing")).toBeInTheDocument();
else expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument();
for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) {
expect(screen.queryByText(`Advanced: ${label}`)).not.toBeInTheDocument();
}
expect(screen.getByText("Advanced: Stalled Task Escalation")).toBeInTheDocument();
expect(screen.getByText("Advanced: Response Format")).toBeInTheDocument();
expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument();
expect(screen.getByText("Advanced: Affinity")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1));
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
const expected = {
classifier_type: capability ? "capability" : "llm_v2",
adaptive: false,
enable_context_window_escalation: false,
escalation_keywords: [],
tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] },
classifier_llm_config: { model: "judge" },
});
};
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject(expected);
},
);

Expand All @@ -239,6 +246,9 @@
expect(screen.getByTestId("template-selector")).toBeInTheDocument();
expandDetailedConfiguration();
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) {
expect(screen.getByText(`Advanced: ${label}`)).toBeInTheDocument();
}
await user.click(screen.getByText("Advanced: Classification Method"));
expect(screen.queryByRole("radio", { name: /^Capability/ })).not.toBeInTheDocument();
expect(screen.queryByRole("radio", { name: /^Fuse v2/ })).not.toBeInTheDocument();
Expand Down Expand Up @@ -490,9 +500,9 @@
const { container } = renderWithProviders(<Harness />);
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "double-submit-router" } });

fireEvent.submit(container.querySelector("form")!);

Check warning on line 503 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library

Check warning on line 503 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid using container methods. Prefer using the methods from Testing Library, such as "getByRole()"
await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled());
fireEvent.submit(container.querySelector("form")!);

Check warning on line 505 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library

Check warning on line 505 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid using container methods. Prefer using the methods from Testing Library, such as "getByRole()"

resolveVerdict({ valid: true });
await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled());
Expand Down Expand Up @@ -544,7 +554,7 @@
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();

await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice");

Check warning on line 557 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library

expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled();
expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument();
Expand All @@ -560,7 +570,7 @@
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice");

Check warning on line 573 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library

await user.click(screen.getByRole("button", { name: "Edit tiers" }));
await user.click(screen.getByRole("button", { name: "Remove the COMPLEX tier" }));
Expand All @@ -579,7 +589,7 @@
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice");

Check warning on line 592 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));

expect(await screen.findAllByText("At least one keyword is required")).toHaveLength(1);
Expand All @@ -596,7 +606,7 @@
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement;

Check warning on line 609 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library

Check warning on line 609 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Avoid direct Node access. Prefer using the methods from Testing Library
await addKeyword(user, keywordsField, "invoice");
await user.click(screen.getByRole("button", { name: /add auto router/i }));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,37 @@ const baseParams: BuildComplexityRouterConfigParams = {
};

describe("buildComplexityRouterConfig", () => {
it.each(["capability", "llm_v2", "heuristic"] as const)(
"disables the removed overrides only for forecast creates: %s",
(classifierType) => {
const forecast = classifierType !== "heuristic";
const params = {
...baseParams,
classifierType,
adaptive: true,
enableContextWindowEscalation: true,
contextWindowEscalationBuffer: 0.9,
};
const config = buildComplexityRouterConfig(params);
expect(config.adaptive).toBe(!forecast);
expect(config.enable_context_window_escalation).toBe(!forecast);
expect(config.escalation_keywords).toEqual(forecast ? [] : ["LITELLM ESCALATE"]);
for (const key of [
"adaptive_weights",
"adaptive_eligible",
"tier_distance_penalty",
"context_window_escalation_buffer",
]) {
expect(Object.hasOwn(config, key)).toBe(!forecast);
}
if (forecast) {
const untouched = buildComplexityRouterConfig({ ...baseParams, classifierType });
expect(untouched.enable_context_window_escalation).toBe(false);
expect(untouched.escalation_keywords).toEqual([]);
}
},
);

it("carries Fast and reasoning overrides independently into a new router payload", () => {
const params = { speed: "fast", reasoning_effort: "high", max_tokens: 1024 };
const config = buildComplexityRouterConfig({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,13 +628,11 @@ export const buildComplexityRouterConfig = ({
// An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
const forecast = isForecastClassifier(effectiveType);

const supportsOpeningPrompt =
!customTierSet && !isForecastClassifier(effectiveType) && usesLlmClassifier(effectiveType);
const supportsOpeningPrompt = !customTierSet && !forecast && usesLlmClassifier(effectiveType);
const payload: ComplexityRouterConfigPayload = {
tiers: isForecastClassifier(effectiveType)
? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0))
: tiers,
tiers: forecast ? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0)) : tiers,
// The backend rejects the flag beside a custom tier set.
...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }),
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
Expand All @@ -645,7 +643,8 @@ export const buildComplexityRouterConfig = ({
...classifierWireFields(effectiveType, classifierInputs),
...(effectiveType === "capability" &&
capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }),
...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config, adaptive: false }),
...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config }),
...(forecast && { adaptive: false }),
// A built-in router's opening instructions. Suppressed beside a legacy whole-prompt override,
// which the backend rejects as a second override of the same prompt.
...(supportsOpeningPrompt &&
Expand All @@ -660,7 +659,7 @@ export const buildComplexityRouterConfig = ({
modality_pin_override: modalityPinOverride ?? false,
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
escalation_keywords: cleanedEscalationKeywords,
escalation_keywords: forecast ? [] : cleanedEscalationKeywords,
// Only written when on: the backend rejects it alongside session_affinity, user_turn mode and
// a custom tier set, so an off router must not carry the key into any of those saves.
...(stallEscalationEnabled && {
Expand All @@ -676,19 +675,21 @@ export const buildComplexityRouterConfig = ({
match_threshold: matchThreshold,
}),
...(adaptive &&
effectiveType !== "llm_v2" && {
!forecast && {
adaptive: true,
adaptive_weights: adaptiveWeights,
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
adaptive_eligible: adaptiveEligible,
}),
...(returnRawModelName && { return_raw_model_name: true }),
...(enableContextWindowEscalation !== undefined && {
enable_context_window_escalation: enableContextWindowEscalation,
}),
...(contextWindowEscalationBuffer !== undefined && {
context_window_escalation_buffer: contextWindowEscalationBuffer,
// Omission enables the backend default, so hidden forecast controls need an explicit opt-out.
...((forecast || enableContextWindowEscalation !== undefined) && {
enable_context_window_escalation: forecast ? false : enableContextWindowEscalation,
}),
...(!forecast &&
contextWindowEscalationBuffer !== undefined && {
context_window_escalation_buffer: contextWindowEscalationBuffer,
}),
...(sessionAffinityTtlSeconds !== undefined && {
session_affinity_ttl_seconds: sessionAffinityTtlSeconds,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ describe("forecast classifier configuration", () => {
});
expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], MEDIUM: ["middle"], REASONING: ["capable"] });
expect(saved.tier_model_configs).toEqual(stored.tier_model_configs);
expect(saved.adaptive).toBe(true);
expect(saved.adaptive).toBe(false);
expect(saved.plan_mode_min_tier).toBe("MEDIUM");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,43 @@ const hydratedState: KeywordMatchingState = {
};

describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
it.each(["capability", "llm_v2", "heuristic"] as const)(
"handles enabled stored overrides when editing %s with or without keyword form state",
(classifier_type) => {
const stored = {
...STORED,
classifier_type,
adaptive: classifier_type !== "llm_v2",
adaptive_weights: { quality: 0.6, cost: 0.4 },
adaptive_eligible: "all",
tier_distance_penalty: 0.8,
enable_context_window_escalation: true,
context_window_escalation_buffer: 0.9,
};
const value = hydrateComplexityRouterConfig(stored, undefined);
for (const keywordState of [undefined, hydratedState]) {
const saved = buildUpdatedComplexityRouterConfig(stored, value, undefined, keywordState);
const forecast = classifier_type !== "heuristic";
expect(saved.adaptive).toBe(!forecast);
expect(saved.enable_context_window_escalation).toBe(!forecast);
expect(saved.escalation_keywords).toEqual(forecast ? [] : stored.escalation_keywords);
for (const key of [
"adaptive_weights",
"adaptive_eligible",
"tier_distance_penalty",
"context_window_escalation_buffer",
]) {
expect(Object.hasOwn(saved, key)).toBe(!forecast);
}
expect(saved.keyword_tier_rules).toEqual(STORED.keyword_tier_rules);
expect(saved.semantic_keyword_matching).toBe(true);
expect(saved.some_future_backend_key).toEqual(STORED.some_future_backend_key);
}
expect(value.enable_context_window_escalation).toBe(true);
expect(stored.escalation_keywords).toEqual(["urgent", "outage"]);
},
);

it("round-trips an untouched edit without changing any keyword-matching value", () => {
// Opening the modal hydrates state from STORED; saving with nothing changed must be a
// no-op. These keys are now MANAGED, so a hydration bug silently wipes them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StoredComplexityRouterConfig } from "../add_model/build_complexity
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
import {
getForecastConfigError,
isForecastClassifier,
capabilitySettingsSchema,
fuseSettingsSchema,
} from "../add_model/forecast_classifier_config";
Expand Down Expand Up @@ -71,6 +72,7 @@ import {
} from "../add_model/heuristic_scoring_knobs";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
effectiveClassifierType,
heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
Expand Down Expand Up @@ -305,6 +307,7 @@ export const buildUpdatedComplexityRouterConfig = (
): Record<string, unknown> => {
const isManaged = (key: string): boolean => {
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
if (key === "escalation_keywords" && isForecastClassifier(effectiveClassifierType(value))) return true;
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
};
Expand Down Expand Up @@ -365,7 +368,7 @@ export const buildUpdatedComplexityRouterConfig = (

// Keys this call does not own stay as the stored config left them.
const unowned: readonly string[] = [
...(keywordMatching === undefined ? KEYWORD_MATCHING_KEYS : []),
...(keywordMatching === undefined ? [...KEYWORD_MATCHING_KEYS].filter((key) => !isManaged(key)) : []),
...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []),
];
return {
Expand Down
Loading