mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
a8979fe054
8 changed files with 122 additions and 42 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue