Merge pull request #41371 from BerriAI/litellm_forecast_simplify_advanced_ui

fix(ui): simplify Capability and Fuse advanced routing options
This commit is contained in:
tin-berri 2026-09-15 21:19:50 -07:00 committed by GitHub
commit a8979fe054
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 122 additions and 42 deletions

View file

@ -794,19 +794,15 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
},
]
: []),
...(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>,
@ -906,15 +902,17 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
},
]
: []),
].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>

View file

@ -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"');

View file

@ -213,17 +213,24 @@ describe("AddAutoRouterTab", () => {
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);
},
);
@ -239,6 +246,9 @@ describe("AddAutoRouterTab", () => {
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();

View file

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

View file

@ -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 }),
@ -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 &&
@ -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 && {
@ -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,
}),

View file

@ -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");
});

View file

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

View file

@ -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";
@ -71,6 +72,7 @@ import {
} from "../add_model/heuristic_scoring_knobs";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
effectiveClassifierType,
heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
@ -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";
};
@ -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 {