mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat(ui): auto-router controls for context-window escalation
Adds an Advanced: Context Window Escalation section to the auto-router form, both create and edit arms, with the toggle for enable_context_window_escalation and a clamped decimal input for context_window_escalation_buffer. An untouched control keeps both keys out of the payload so the router tracks the backend defaults; an explicit opt-out (false) survives the edit round-trip through the managed-keys projection and the hydrator, and preset prefill maps both keys straight through so a preset cannot silently drop them Resolves LIT-6601
This commit is contained in:
parent
3fadcd7155
commit
3793366a43
10 changed files with 176 additions and 0 deletions
|
|
@ -29,6 +29,7 @@ import React from "react";
|
|||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import ContextWindowEscalationConfig from "./ContextWindowEscalationConfig";
|
||||
import { Restricted, restrictedBy } from "./TierRestrictions";
|
||||
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
|
||||
import {
|
||||
|
|
@ -392,6 +393,13 @@ export interface ComplexityRouterConfigValue {
|
|||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
/**
|
||||
* Context-window escalation gate. Undefined means untouched, which keeps both keys out of the
|
||||
* payload so the router tracks the backend defaults (enabled, 0.95 buffer); an explicit false
|
||||
* is a real opt-out and must survive the edit round-trip.
|
||||
*/
|
||||
enable_context_window_escalation?: boolean;
|
||||
context_window_escalation_buffer?: number;
|
||||
/**
|
||||
* Heuristic scorer knobs. Undefined means the operator never touched them, which keeps the key out of the
|
||||
* payload so the router tracks the backend defaults rather than freezing today's numbers.
|
||||
|
|
@ -827,6 +835,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "context-window",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
|
||||
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "response",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const ContextWindowEscalationConfig: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const enabled = value.enable_context_window_escalation ?? true;
|
||||
const [bufferDraft, setBufferDraft] = React.useState<string | null>(null);
|
||||
// min/max are inert on a text input, and a plain number input renders Number("0.") as "0" so a
|
||||
// decimal cannot be typed. Hence the local draft plus an explicit clamp on commit.
|
||||
const commitBuffer = (raw: string) => {
|
||||
setBufferDraft(null);
|
||||
const parsed = Number(raw);
|
||||
if (raw.trim() === "" || !Number.isFinite(parsed)) return;
|
||||
onChange({ ...value, context_window_escalation_buffer: Math.min(1, Math.max(0.01, parsed)) });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => onChange({ ...value, enable_context_window_escalation: next })}
|
||||
aria-label="Escalate oversized prompts to a tier that fits"
|
||||
/>
|
||||
<strong className="font-semibold">Escalate oversized prompts to a tier that fits</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
When a prompt provably cannot fit the decided tier's context windows, route it to the lowest tier whose
|
||||
window holds it instead of letting the provider reject it. Off means requests dispatch on complexity alone.
|
||||
</span>
|
||||
{enabled && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="context-window-escalation-buffer">
|
||||
Window fit buffer
|
||||
</label>
|
||||
<Input
|
||||
id="context-window-escalation-buffer"
|
||||
inputMode="decimal"
|
||||
value={bufferDraft ?? value.context_window_escalation_buffer ?? ""}
|
||||
placeholder="0.95"
|
||||
onChange={(event) => setBufferDraft(event.target.value)}
|
||||
onBlur={(event) => commitBuffer(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
Fraction of a model's window the counted prompt must fit within, above 0 up to 1. Empty tracks the
|
||||
backend default of 0.95.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextWindowEscalationConfig;
|
||||
|
|
@ -373,6 +373,50 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("carries a context-window escalation opt-out through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-window-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const toggle = await screen.findByRole("switch", { name: "Escalate oversized prompts to a tier that fits" });
|
||||
expect(toggle).toBeChecked();
|
||||
await user.click(toggle);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
enable_context_window_escalation: false,
|
||||
});
|
||||
});
|
||||
|
||||
// A plain number input renders Number("1.5") fine but the clamp is ours: values above 1 must commit as 1,
|
||||
// and an untouched buffer must stay out of the payload so the router tracks the backend default.
|
||||
it("clamps the context-window buffer to 1 and keeps an untouched buffer out of the payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-buffer-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const buffer = await screen.findByLabelText("Window fit buffer");
|
||||
fireEvent.change(buffer, { target: { value: "1.5" } });
|
||||
fireEvent.blur(buffer, { target: { value: "1.5" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
const config = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config;
|
||||
expect(config).toMatchObject({ context_window_escalation_buffer: 1 });
|
||||
expect(config).not.toHaveProperty("enable_context_window_escalation");
|
||||
});
|
||||
|
||||
// The scalar floor is the one scorer knob with no group dict behind it, so its wiring into the create
|
||||
// payload is only proven end to end. 0 is the case a truthy check would silently drop.
|
||||
it("carries a reasoning override floor of 0 through to the create payload", async () => {
|
||||
|
|
|
|||
|
|
@ -367,6 +367,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
tokenThresholds: complexityRouterConfig.token_thresholds,
|
||||
dimensionWeights: complexityRouterConfig.dimension_weights,
|
||||
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
|
||||
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
|
||||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,18 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(config).toEqual(expected);
|
||||
});
|
||||
|
||||
// False is the value a truthy guard would silently drop, and it is the whole point of the toggle:
|
||||
// an untouched form tracks the backend default (enabled), an explicit false is a real opt-out.
|
||||
it("carries an explicit context-window escalation opt-out and buffer, false included", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
enableContextWindowEscalation: false,
|
||||
contextWindowEscalationBuffer: 0.9,
|
||||
});
|
||||
expect(config.enable_context_window_escalation).toBe(false);
|
||||
expect(config.context_window_escalation_buffer).toBe(0.9);
|
||||
});
|
||||
|
||||
it("trims escalation keywords and drops blank entries", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ export interface BuildComplexityRouterConfigParams {
|
|||
dimensionWeights?: DimensionWeights;
|
||||
reasoningOverrideMinScore?: number;
|
||||
tierModelParams?: TierModelParamsByTier;
|
||||
enableContextWindowEscalation?: boolean;
|
||||
contextWindowEscalationBuffer?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,6 +176,8 @@ export interface ComplexityRouterConfigPayload {
|
|||
token_thresholds?: TokenThresholds;
|
||||
dimension_weights?: DimensionWeights;
|
||||
reasoning_override_min_score?: number;
|
||||
enable_context_window_escalation?: boolean;
|
||||
context_window_escalation_buffer?: number;
|
||||
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
|
||||
}
|
||||
|
||||
|
|
@ -407,6 +411,8 @@ export const buildComplexityRouterConfig = ({
|
|||
dimensionWeights,
|
||||
reasoningOverrideMinScore,
|
||||
tierModelParams,
|
||||
enableContextWindowEscalation,
|
||||
contextWindowEscalationBuffer,
|
||||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const serializedTierModelConfigs = customTierSet
|
||||
? serializeTierModelConfigs(
|
||||
|
|
@ -463,6 +469,12 @@ export const buildComplexityRouterConfig = ({
|
|||
adaptive_eligible: adaptiveEligible,
|
||||
}),
|
||||
...(returnRawModelName && { return_raw_model_name: true }),
|
||||
...(enableContextWindowEscalation !== undefined && {
|
||||
enable_context_window_escalation: enableContextWindowEscalation,
|
||||
}),
|
||||
...(contextWindowEscalationBuffer !== undefined && {
|
||||
context_window_escalation_buffer: contextWindowEscalationBuffer,
|
||||
}),
|
||||
...scorerKnobs,
|
||||
};
|
||||
if (!customTierSet) return payload;
|
||||
|
|
|
|||
|
|
@ -487,6 +487,8 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
token_thresholds: { simple: 20, complex: 500 },
|
||||
dimension_weights: { tokenCount: 0.1 },
|
||||
reasoning_override_min_score: 0.3,
|
||||
enable_context_window_escalation: false,
|
||||
context_window_escalation_buffer: 0.9,
|
||||
};
|
||||
|
||||
// tier_definitions, fallback_tier and classification_prompt cannot sit beside heuristic_first, which
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export interface StoredComplexityRouterConfig {
|
|||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
enable_context_window_escalation?: unknown;
|
||||
context_window_escalation_buffer?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -178,6 +180,14 @@ export const hydrateComplexityRouterConfig = (
|
|||
tier_distance_penalty: parsedConfig.tier_distance_penalty,
|
||||
adaptive_eligible: parsedConfig.adaptive_eligible || "all",
|
||||
return_raw_model_name: parsedConfig.return_raw_model_name || false,
|
||||
enable_context_window_escalation:
|
||||
typeof parsedConfig.enable_context_window_escalation === "boolean"
|
||||
? parsedConfig.enable_context_window_escalation
|
||||
: undefined,
|
||||
context_window_escalation_buffer:
|
||||
typeof parsedConfig.context_window_escalation_buffer === "number"
|
||||
? parsedConfig.context_window_escalation_buffer
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -208,6 +218,8 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"token_thresholds",
|
||||
"dimension_weights",
|
||||
"reasoning_override_min_score",
|
||||
"enable_context_window_escalation",
|
||||
"context_window_escalation_buffer",
|
||||
]);
|
||||
|
||||
// Managed only when the caller passes the corresponding state. A caller that does not render
|
||||
|
|
@ -307,6 +319,8 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
dimensionWeights: value.dimension_weights,
|
||||
reasoningOverrideMinScore: value.reasoning_override_min_score,
|
||||
tierModelParams: value.tier_model_params,
|
||||
enableContextWindowEscalation: value.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: value.context_window_escalation_buffer,
|
||||
};
|
||||
const built = buildComplexityRouterConfig(builderParams);
|
||||
|
||||
|
|
|
|||
|
|
@ -573,6 +573,24 @@ describe("autorouter_presets", () => {
|
|||
expect(prefill.escalationKeywords).toEqual([]);
|
||||
});
|
||||
|
||||
// The prefill mapping is a hand-written field list, so a new config key is silently dropped
|
||||
// unless mapped; false is the value a `||` default would erase (the #38453 class).
|
||||
it("carries a preset's context-window escalation opt-out and buffer through the prefill", () => {
|
||||
const prefill = buildPresetPrefill(
|
||||
{
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
enable_context_window_escalation: false,
|
||||
context_window_escalation_buffer: 0.9,
|
||||
},
|
||||
groupsOnly(["gpt-5-nano"]),
|
||||
);
|
||||
expect(prefill.complexityRouterConfig.enable_context_window_escalation).toBe(false);
|
||||
expect(prefill.complexityRouterConfig.context_window_escalation_buffer).toBe(0.9);
|
||||
});
|
||||
|
||||
it("falls back to the defaults when a preset omits match_threshold and escalation_keywords", () => {
|
||||
const prefill = buildPresetPrefill(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@ export const buildPresetPrefill = (
|
|||
tier_distance_penalty: config.tier_distance_penalty,
|
||||
adaptive_eligible: config.adaptive_eligible,
|
||||
return_raw_model_name: config.return_raw_model_name,
|
||||
enable_context_window_escalation: config.enable_context_window_escalation,
|
||||
context_window_escalation_buffer: config.context_window_escalation_buffer,
|
||||
},
|
||||
customTechnicalKeywords: config.custom_technical_keywords ?? [],
|
||||
keywordTierRules: hydrateKeywordTierRules(config.keyword_tier_rules ?? []),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue