mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(ui): auto-router controls for context-window escalation (#39054)
* 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 * fix(ui): clearing the context-window buffer removes it from the payload Both review bots converged on the same defect: an emptied buffer field early-returned in commitBuffer, the draft was discarded on blur, and the stale number reappeared and stayed in the saved config, contradicting the copy that an empty field tracks the backend default. An empty commit now removes the key, which the managed-keys projection propagates as a real deletion on edit. Also trims the narrative comments the review flagged as restating behavior
This commit is contained in:
parent
0565d33fa5
commit
502b3a2f79
10 changed files with 196 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,60 @@
|
|||
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;
|
||||
// A number input renders Number("0.") as "0", so a decimal cannot be typed without a local draft.
|
||||
const [bufferDraft, setBufferDraft] = React.useState<string | null>(null);
|
||||
const commitBuffer = (raw: string) => {
|
||||
setBufferDraft(null);
|
||||
if (raw.trim() === "") {
|
||||
onChange({ ...value, context_window_escalation_buffer: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!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,71 @@ 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,
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("clearing the buffer removes it from the payload so the router tracks the backend default", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-clear-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const buffer = await screen.findByLabelText("Window fit buffer");
|
||||
fireEvent.change(buffer, { target: { value: "0.8" } });
|
||||
fireEvent.blur(buffer, { target: { value: "0.8" } });
|
||||
fireEvent.change(buffer, { target: { value: "" } });
|
||||
fireEvent.blur(buffer, { target: { value: "" } });
|
||||
|
||||
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).not.toHaveProperty(
|
||||
"context_window_escalation_buffer",
|
||||
);
|
||||
});
|
||||
|
||||
// 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,16 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(config).toEqual(expected);
|
||||
});
|
||||
|
||||
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,22 @@ describe("autorouter_presets", () => {
|
|||
expect(prefill.escalationKeywords).toEqual([]);
|
||||
});
|
||||
|
||||
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