diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md
index e3da70f50fe..afa27719064 100644
--- a/litellm/router_strategy/complexity_router/README.md
+++ b/litellm/router_strategy/complexity_router/README.md
@@ -190,6 +190,9 @@ model_list:
# Let that replacement also override a kept session pin, for image turns only (default: false)
modality_pin_override: true
+
+ # Refreshes on every pin reuse, so this is idle time rather than total session length (default: 3600)
+ session_affinity_ttl_seconds: 300
```
## Usage
@@ -240,6 +243,10 @@ affinity write happens upstream of the gate and stores the session's own model,
turn replays the original pin and the override is never pinned in its place. It does nothing
unless `modality_routing` is also on.
+### Session pin retention
+
+`session_affinity_ttl_seconds` is the idle window for both the model pin selected by session affinity and the deployment pin. Every request that reuses a pin refreshes its TTL, so a session actively sending requests stays pinned. After the window passes with no pin reuse, the next request classifies again and creates a fresh pin. Omit the setting to track the default of 3600 seconds.
+
### Heuristic-first chaining
`classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM
diff --git a/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx b/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx
index 4d9e2122739..9022d424369 100644
--- a/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx
@@ -1,26 +1,58 @@
import React from "react";
+import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
-import { DEFAULT_DEPLOYMENT_AFFINITY } from "./ComplexityRouterConfig";
+import { DEFAULT_DEPLOYMENT_AFFINITY, DEFAULT_SESSION_AFFINITY_TTL_SECONDS } from "./ComplexityRouterConfig";
export const AffinityControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
-}> = ({ value, onChange }) => (
- <>
-
- onChange({ ...value, deployment_affinity: deploymentAffinity })}
- aria-label="Pin a session to one deployment per model group"
- />
- Pin a session to one deployment per model group
-
-
- Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to
- load-balance every turn.
-
- >
-);
+}> = ({ value, onChange }) => {
+ const [ttlDraft, setTtlDraft] = React.useState(null);
+ const commitTtl = (raw: string) => {
+ setTtlDraft(null);
+ if (raw.trim() === "") {
+ onChange({ ...value, session_affinity_ttl_seconds: undefined });
+ return;
+ }
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) return;
+ onChange({ ...value, session_affinity_ttl_seconds: Math.max(1, Math.round(parsed)) });
+ };
+
+ return (
+ <>
+
+ onChange({ ...value, deployment_affinity: deploymentAffinity })}
+ aria-label="Pin a session to one deployment per model group"
+ />
+ Pin a session to one deployment per model group
+
+
+ Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to
+ load-balance every turn.
+
+
+
+ How long a pin survives idle (seconds)
+
+ setTtlDraft(event.target.value)}
+ onBlur={(event) => commitTtl(event.target.value)}
+ />
+
+ Refreshes after every request that reuses a pin. Empty tracks the backend default of{" "}
+ {DEFAULT_SESSION_AFFINITY_TTL_SECONDS} seconds.
+
+
+ >
+ );
+};
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx
index 02cb0543e17..bdca5205b2e 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx
@@ -947,6 +947,46 @@ describe("ComplexityRouterConfig affinity panel", () => {
expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).not.toBeChecked();
});
+
+ it("writes an idle TTL on blur and keeps the partial input as a draft while typing", () => {
+ const onChange = vi.fn();
+ renderWithProviders( );
+ fireEvent.click(screen.getByText("Advanced: Affinity"));
+
+ const ttl = screen.getByLabelText("How long a pin survives idle (seconds)");
+ expect(ttl).toHaveAttribute("placeholder", "3600");
+ fireEvent.change(ttl, { target: { value: "300" } });
+ expect(onChange).not.toHaveBeenCalled();
+ fireEvent.blur(ttl);
+
+ expect(onChange).toHaveBeenCalledWith({ ...defaultValue, session_affinity_ttl_seconds: 300 });
+ });
+
+ it("clearing the idle TTL returns the router to its backend default", () => {
+ const onChange = vi.fn();
+ const value = { ...defaultValue, session_affinity_ttl_seconds: 300 };
+ renderWithProviders( );
+ fireEvent.click(screen.getByText("Advanced: Affinity"));
+
+ const ttl = screen.getByLabelText("How long a pin survives idle (seconds)");
+ expect(ttl).toHaveValue("300");
+ fireEvent.change(ttl, { target: { value: "" } });
+ fireEvent.blur(ttl);
+
+ expect(onChange).toHaveBeenCalledWith({ ...value, session_affinity_ttl_seconds: undefined });
+ });
+
+ it("clamps a non-positive idle TTL to the backend's minimum", () => {
+ const onChange = vi.fn();
+ renderWithProviders( );
+ fireEvent.click(screen.getByText("Advanced: Affinity"));
+
+ const ttl = screen.getByLabelText("How long a pin survives idle (seconds)");
+ fireEvent.change(ttl, { target: { value: "0" } });
+ fireEvent.blur(ttl);
+
+ expect(onChange).toHaveBeenCalledWith({ ...defaultValue, session_affinity_ttl_seconds: 1 });
+ });
});
describe("ComplexityRouterConfig default model", () => {
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
index 791fd1fcfbb..06363830d64 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
@@ -58,6 +58,7 @@ export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
export const DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS = 8000;
export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120;
export const DEFAULT_SESSION_AFFINITY = false;
+export const DEFAULT_SESSION_AFFINITY_TTL_SECONDS = 3600;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
export type ClassificationMode = "every_request" | "user_turn";
@@ -411,6 +412,7 @@ export interface ComplexityRouterConfigValue {
hybrid_boundary_margin?: number;
classification_mode?: ClassificationMode;
session_affinity?: boolean;
+ session_affinity_ttl_seconds?: number;
modality_routing?: boolean;
modality_pin_override?: boolean;
deployment_affinity?: boolean;
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 836b4c6ff3d..dfef8171c51 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -493,7 +493,7 @@ describe("AddAutoRouterTab", () => {
});
});
- it("carries session affinity turned on through to the create payload", async () => {
+ it("carries session affinity turned on and its idle window through to the create payload", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
@@ -503,12 +503,17 @@ describe("AddAutoRouterTab", () => {
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Once per session/ }));
+ await user.click(screen.getByText("Advanced: Affinity"));
+ const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)");
+ fireEvent.change(ttl, { target: { value: "300" } });
+ fireEvent.blur(ttl);
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({
session_affinity: true,
+ session_affinity_ttl_seconds: 300,
});
});
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index c335beade91..1a1725b8dd0 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -388,6 +388,7 @@ const AddAutoRouterTab: React.FC = ({
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
+ sessionAffinityTtlSeconds: complexityRouterConfig.session_affinity_ttl_seconds,
};
const submitRecommendedRouter = async (name: string) => {
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index 53ab859baa0..9ee555f5dd2 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -73,6 +73,15 @@ describe("buildComplexityRouterConfig", () => {
expect(config.context_window_escalation_buffer).toBe(0.9);
});
+ it("omits session_affinity_ttl_seconds when untouched, so the router tracks the backend default", () => {
+ expect(buildComplexityRouterConfig(baseParams)).not.toHaveProperty("session_affinity_ttl_seconds");
+ });
+
+ it("emits an explicit session affinity idle window", () => {
+ const config = buildComplexityRouterConfig({ ...baseParams, sessionAffinityTtlSeconds: 300 });
+ expect(config.session_affinity_ttl_seconds).toBe(300);
+ });
+
it("trims escalation keywords and drops blank entries", () => {
const config = buildComplexityRouterConfig({
...baseParams,
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index b8af55e8c8a..956e593a234 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -138,6 +138,7 @@ export interface BuildComplexityRouterConfigParams {
tierModelParams?: TierModelParamsByTier;
enableContextWindowEscalation?: boolean;
contextWindowEscalationBuffer?: number;
+ sessionAffinityTtlSeconds?: number;
}
/**
@@ -175,6 +176,7 @@ export interface ComplexityRouterConfigPayload {
hybrid_boundary_margin?: number;
classification_mode: ClassificationMode;
session_affinity: boolean;
+ session_affinity_ttl_seconds?: number;
deployment_affinity: boolean;
modality_routing: boolean;
modality_pin_override: boolean;
@@ -456,6 +458,7 @@ export const buildComplexityRouterConfig = ({
tierModelParams,
enableContextWindowEscalation,
contextWindowEscalationBuffer,
+ sessionAffinityTtlSeconds,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = customTierSet
? serializeTierModelConfigs(
@@ -522,6 +525,9 @@ export const buildComplexityRouterConfig = ({
...(contextWindowEscalationBuffer !== undefined && {
context_window_escalation_buffer: contextWindowEscalationBuffer,
}),
+ ...(sessionAffinityTtlSeconds !== undefined && {
+ session_affinity_ttl_seconds: sessionAffinityTtlSeconds,
+ }),
...scorerKnobs,
};
if (!customTierSet) return payload;
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index f24f3033901..877b35199a4 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -257,6 +257,43 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => {
});
});
+describe("buildUpdatedComplexityRouterConfig session affinity ttl", () => {
+ it("writes an edited idle window", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, session_affinity_ttl_seconds: 300 });
+ expect(result.session_affinity_ttl_seconds).toBe(300);
+ });
+
+ it("carries a stored idle window through an untouched open-and-save", () => {
+ const stored = { ...STORED, session_affinity_ttl_seconds: 900 };
+ const result = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
+ expect(result.session_affinity_ttl_seconds).toBe(900);
+ });
+
+ it("drops the key when the field is cleared, so the router goes back to tracking the backend default", () => {
+ const result = buildUpdatedComplexityRouterConfig(
+ { ...STORED, session_affinity_ttl_seconds: 900 },
+ { ...FORM_VALUE, session_affinity_ttl_seconds: undefined },
+ );
+ expect(result).not.toHaveProperty("session_affinity_ttl_seconds");
+ });
+
+ it("keeps the idle window on a custom tier set, whose deployment pin still uses it", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, {
+ ...FORM_VALUE,
+ session_affinity_ttl_seconds: 300,
+ custom_tier_set: {
+ tiers: [
+ { id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
+ { id: "b", name: "AUDIT", definition: "security review", models: ["o1"] },
+ ],
+ fallback_tier_id: "a",
+ },
+ });
+ expect(result.session_affinity).toBe(false);
+ expect(result.session_affinity_ttl_seconds).toBe(300);
+ });
+});
+
describe("buildUpdatedComplexityRouterConfig modality pin override", () => {
it("writes modality_pin_override explicitly both ways", () => {
expect(
@@ -529,6 +566,7 @@ describe("managed keys survive an untouched open-and-save", () => {
classifier_fallback: "default_model",
classification_mode: "user_turn",
session_affinity: true,
+ session_affinity_ttl_seconds: 300,
modality_routing: true,
modality_pin_override: true,
deployment_affinity: false,
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
index 6067e72e547..970bcaa545f 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
@@ -550,6 +550,49 @@ describe("EditAutoRouterModal deployment affinity", () => {
expect(savedConfig().deployment_affinity).toBe(false);
});
+ it("preserves an idle TTL through an untouched save", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig({ ...STORED_CONFIG, session_affinity_ttl_seconds: 300 });
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ expect(await screen.findByLabelText("How long a pin survives idle (seconds)")).toHaveValue("300");
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig().session_affinity_ttl_seconds).toBe(300);
+ });
+
+ it("persists an edited idle TTL", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig(STORED_CONFIG);
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)");
+ fireEvent.change(ttl, { target: { value: "300" } });
+ fireEvent.blur(ttl);
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig().session_affinity_ttl_seconds).toBe(300);
+ });
+
+ it("removes the idle TTL when cleared", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig({ ...STORED_CONFIG, session_affinity_ttl_seconds: 300 });
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)");
+ fireEvent.change(ttl, { target: { value: "" } });
+ fireEvent.blur(ttl);
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig()).not.toHaveProperty("session_affinity_ttl_seconds");
+ });
+
// modality_pin_override is a managed key, so the modal rewrites it from form state on save. A
// hydration gap would silently turn a stored override off on the next untouched save.
it("shows a stored modality_pin_override=true as on and preserves it through an untouched save", async () => {
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 54766df93ec..ea1e5cba6a3 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -104,6 +104,7 @@ export interface StoredComplexityRouterConfig {
dimension_weights?: unknown;
reasoning_override_min_score?: unknown;
session_affinity?: unknown;
+ session_affinity_ttl_seconds?: unknown;
modality_routing?: unknown;
modality_pin_override?: unknown;
deployment_affinity?: unknown;
@@ -182,6 +183,11 @@ export const hydrateComplexityRouterConfig = (
reasoning_override_min_score: hydrateReasoningOverrideMinScore(parsedConfig.reasoning_override_min_score),
session_affinity:
typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY,
+ session_affinity_ttl_seconds:
+ typeof parsedConfig.session_affinity_ttl_seconds === "number" &&
+ Number.isFinite(parsedConfig.session_affinity_ttl_seconds)
+ ? parsedConfig.session_affinity_ttl_seconds
+ : undefined,
modality_routing: typeof parsedConfig.modality_routing === "boolean" ? parsedConfig.modality_routing : false,
modality_pin_override:
typeof parsedConfig.modality_pin_override === "boolean" ? parsedConfig.modality_pin_override : false,
@@ -224,6 +230,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"hybrid_boundary_margin",
"classification_mode",
"session_affinity",
+ "session_affinity_ttl_seconds",
"modality_routing",
"modality_pin_override",
"deployment_affinity",
@@ -322,6 +329,7 @@ export const buildUpdatedComplexityRouterConfig = (
classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns,
classifierFallback: value.classifier_fallback,
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
+ sessionAffinityTtlSeconds: value.session_affinity_ttl_seconds,
modalityRouting: value.modality_routing ?? false,
modalityPinOverride: value.modality_pin_override ?? false,
deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index 3dd9911c794..ffede7b2a6b 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -83,9 +83,19 @@ describe("autorouter_presets", () => {
expect(config.tier_boundaries).toBeUndefined();
expect(config.token_thresholds).toBeUndefined();
expect(config.dimension_weights).toBeUndefined();
+ expect(config.session_affinity_ttl_seconds).toBeUndefined();
}
});
+ it("carries a preset's session affinity idle window into the prefilled form state", () => {
+ const config = getPresetByKey("anthropic_family")!.complexity_router_config;
+ const prefill = buildPresetPrefill({ ...config, session_affinity_ttl_seconds: 300 }, groupsOnly([]));
+ expect(prefill.complexityRouterConfig.session_affinity_ttl_seconds).toBe(300);
+ expect(
+ buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig.session_affinity_ttl_seconds,
+ ).toBeUndefined();
+ });
+
it("keeps the model-family presets on the heuristic classifier", () => {
for (const key of ["anthropic_family", "gemini_family", "openai_family"]) {
expect(getPresetByKey(key)!.complexity_router_config.classifier_type).toBe("heuristic");
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index ff482bd23b8..b01108f1631 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -284,6 +284,7 @@ export const buildPresetPrefill = (
classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE,
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
+ session_affinity_ttl_seconds: config.session_affinity_ttl_seconds,
deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
modality_routing: config.modality_routing ?? false,
modality_pin_override: config.modality_pin_override ?? false,