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:
Tin Chi Lo 2026-08-31 19:27:16 -07:00
parent 3fadcd7155
commit 3793366a43
10 changed files with 176 additions and 0 deletions

View file

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

View file

@ -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&apos;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&apos;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;

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ?? []),