= ({
children: ,
},
{
- key: "session-affinity",
+ key: "affinity",
label: (
- Advanced: Session Affinity
+ Advanced: Affinity
),
children: (
<>
+
+ 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.
+
= ({
Pin a session to its first model
- Off by default: every turn is classified on its own merits and routed to the cheapest adequate tier.
- Turn this on to reuse the model chosen on a session's first turn for every later turn, which
- preserves provider prompt caches and avoids cross-model conversation-history errors, at the cost of
- keeping the whole session on the first turn's tier.
+ Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the
+ deployment.
>
),
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 6a5a1e0f159a..7d42d85ccfba 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
@@ -273,7 +273,7 @@ describe("AddAutoRouterTab", () => {
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
expandDetailedConfiguration();
- await user.click(screen.getByText("Advanced: Session Affinity"));
+ await user.click(screen.getByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@@ -292,7 +292,7 @@ describe("AddAutoRouterTab", () => {
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
expandDetailedConfiguration();
- await user.click(screen.getByText("Advanced: Session Affinity"));
+ await user.click(screen.getByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@@ -303,6 +303,46 @@ describe("AddAutoRouterTab", () => {
});
});
+ it("defaults a new router to deployment affinity on, matching the backend field default", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getMissingTiersError).mockReturnValue(null);
+
+ renderWithProviders();
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
+ expandDetailedConfiguration();
+ await user.click(screen.getByText("Advanced: Affinity"));
+ expect(
+ await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }),
+ ).toBeChecked();
+
+ 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({
+ deployment_affinity: true,
+ });
+ });
+
+ it("carries deployment affinity turned off through to the create payload", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getMissingTiersError).mockReturnValue(null);
+
+ renderWithProviders();
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
+ expandDetailedConfiguration();
+ await user.click(screen.getByText("Advanced: Affinity"));
+ await user.click(await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }));
+
+ 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({
+ deployment_affinity: false,
+ });
+ });
+
// Custom is the escape hatch, not the headline choice, so it's listed after every bundled preset
// rather than first.
it("lists Custom Configuration after the bundled presets", () => {
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 08041413d1f8..7b6403f250c7 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
@@ -15,6 +15,7 @@ import ComplexityRouterConfig, {
ComplexityTiers,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
+ DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
} from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
@@ -290,6 +291,7 @@ const AddAutoRouterTab: React.FC = ({
classifierContextIncludeAssistantTurns: complexityRouterConfig.classifier_context_include_assistant_turns,
classifierFallback: complexityRouterConfig.classifier_fallback,
sessionAffinity: complexityRouterConfig.session_affinity ?? DEFAULT_SESSION_AFFINITY,
+ deploymentAffinity: complexityRouterConfig.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
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 34f7354d412a..1a2276f302d1 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
@@ -26,6 +26,7 @@ const baseParams: BuildComplexityRouterConfigParams = {
classifierContextIncludeAssistantTurns: undefined,
classifierFallback: undefined,
sessionAffinity: false,
+ deploymentAffinity: true,
customTechnicalKeywords: [],
keywordTierRules: [],
semanticMatchingEnabled: false,
@@ -46,6 +47,7 @@ describe("buildComplexityRouterConfig", () => {
tiers,
classifier_type: "heuristic",
session_affinity: false,
+ deployment_affinity: true,
escalation_keywords: ["LITELLM ESCALATE"],
});
});
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 9ed1b98db7b2..503390aa2386 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
@@ -30,6 +30,7 @@ export interface BuildComplexityRouterConfigParams {
classifierContextIncludeAssistantTurns: boolean | undefined;
classifierFallback: ClassifierFallback | undefined;
sessionAffinity: boolean;
+ deploymentAffinity: boolean;
customTechnicalKeywords: string[];
keywordTierRules: KeywordTierRule[];
semanticMatchingEnabled: boolean;
@@ -53,6 +54,7 @@ export interface ComplexityRouterConfigPayload {
classifier_context_include_assistant_turns?: boolean;
classifier_fallback?: ClassifierFallback;
session_affinity: boolean;
+ deployment_affinity: boolean;
custom_technical_keywords?: string[];
keyword_tier_rules?: { keywords: string[]; tier: KeywordTierRule["tier"] }[];
semantic_keyword_matching?: boolean;
@@ -137,6 +139,7 @@ export const buildComplexityRouterConfig = ({
classifierContextIncludeAssistantTurns,
classifierFallback,
sessionAffinity,
+ deploymentAffinity,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
@@ -173,6 +176,7 @@ export const buildComplexityRouterConfig = ({
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
session_affinity: sessionAffinity,
+ deployment_affinity: deploymentAffinity,
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
escalation_keywords: cleanedEscalationKeywords,
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 30859b5baeac..b56ec734aebf 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
@@ -228,6 +228,31 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => {
});
});
+describe("buildUpdatedComplexityRouterConfig deployment affinity", () => {
+ it("writes deployment_affinity=false when the toggle is off", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, deployment_affinity: false });
+ expect(result.deployment_affinity).toBe(false);
+ });
+
+ it("writes deployment_affinity=true when the toggle is on", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, deployment_affinity: true });
+ expect(result.deployment_affinity).toBe(true);
+ });
+
+ it("re-asserts the backend's on-by-default when the form value is absent, rather than dropping the key", () => {
+ const result = buildUpdatedComplexityRouterConfig({ ...STORED, deployment_affinity: false }, FORM_VALUE);
+ expect(result.deployment_affinity).toBe(true);
+ });
+
+ it("stops a stored deployment_affinity=false from surviving a save that turned the toggle back on", () => {
+ const result = buildUpdatedComplexityRouterConfig(
+ { ...STORED, deployment_affinity: false },
+ { ...FORM_VALUE, deployment_affinity: true },
+ );
+ expect(result.deployment_affinity).toBe(true);
+ });
+});
+
describe("buildUpdatedComplexityRouterConfig tier labels", () => {
const RENAMED = { ...STORED, tier_labels: { SIMPLE: "Cheap", REASONING: "Deep" } };
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts
index eb5bb46f0e88..027e01a93515 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts
@@ -48,6 +48,7 @@ const expectedClassifiedTierConfig = {
embedding_model: "voyage-4-large",
match_threshold: 0.65,
session_affinity: false,
+ deployment_affinity: true,
adaptive: true,
adaptive_weights: { quality: 0.4, cost: 0.6 },
adaptive_eligible: "classified_tier",
@@ -68,6 +69,7 @@ const expectedAdaptiveDisabledConfig = {
embedding_model: "voyage-4-large",
match_threshold: 0.65,
session_affinity: false,
+ deployment_affinity: true,
};
describe("buildUpdatedComplexityRouterConfig", () => {
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 9b36218e17a8..9f05169b2742 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
@@ -342,7 +342,7 @@ describe("EditAutoRouterModal session affinity", () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);
- await user.click(await screen.findByText("Advanced: Session Affinity"));
+ await user.click(await screen.findByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@@ -355,7 +355,7 @@ describe("EditAutoRouterModal session affinity", () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
- await user.click(await screen.findByText("Advanced: Session Affinity"));
+ await user.click(await screen.findByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@@ -368,7 +368,7 @@ describe("EditAutoRouterModal session affinity", () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);
- await user.click(await screen.findByText("Advanced: Session Affinity"));
+ await user.click(await screen.findByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
@@ -381,7 +381,7 @@ describe("EditAutoRouterModal session affinity", () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
- await user.click(await screen.findByText("Advanced: Session Affinity"));
+ await user.click(await screen.findByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
@@ -391,6 +391,67 @@ describe("EditAutoRouterModal session affinity", () => {
});
});
+describe("EditAutoRouterModal deployment affinity", () => {
+ beforeEach(() => {
+ modelPatchUpdateCall.mockClear();
+ });
+
+ const renderWithStoredConfig = (complexity_router_config: Record) =>
+ renderWithProviders(
+ ,
+ );
+
+ it("shows a stored config with no deployment_affinity key as on, matching the backend default", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig(STORED_CONFIG);
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ expect(
+ await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }),
+ ).toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig().deployment_affinity).toBe(true);
+ });
+
+ it("shows a stored deployment_affinity=false as off and preserves it through an untouched save", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig({ ...STORED_CONFIG, deployment_affinity: false });
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ expect(
+ await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }),
+ ).not.toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig().deployment_affinity).toBe(false);
+ });
+
+ it("persists turning deployment affinity off", async () => {
+ const user = userEvent.setup();
+ renderWithStoredConfig(STORED_CONFIG);
+
+ await user.click(await screen.findByText("Advanced: Affinity"));
+ await user.click(await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }));
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+ expect(savedConfig().deployment_affinity).toBe(false);
+ });
+});
+
describe("EditAutoRouterModal custom classifier prompt and fallback", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
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 cf1a57279489..8b3b09679e00 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
@@ -21,6 +21,7 @@ import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
+ DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
} from "../add_model/ComplexityRouterConfig";
import NotificationsManager from "../molecules/notifications_manager";
@@ -47,6 +48,7 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"classifier_context_include_assistant_turns",
"classifier_fallback",
"session_affinity",
+ "deployment_affinity",
"adaptive",
"adaptive_weights",
"tier_distance_penalty",
@@ -119,6 +121,7 @@ export const buildUpdatedComplexityRouterConfig = (
classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns,
}),
session_affinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
+ deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
...(customTechnicalKeywords &&
customTechnicalKeywords.length > 0 && {
custom_technical_keywords: customTechnicalKeywords,
@@ -255,6 +258,10 @@ const EditAutoRouterModal: React.FC = ({
typeof parsedConfig.session_affinity === "boolean"
? parsedConfig.session_affinity
: DEFAULT_SESSION_AFFINITY,
+ deployment_affinity:
+ typeof parsedConfig.deployment_affinity === "boolean"
+ ? parsedConfig.deployment_affinity
+ : DEFAULT_DEPLOYMENT_AFFINITY,
adaptive: parsedConfig.adaptive || false,
adaptive_weights: parsedConfig.adaptive_weights,
tier_distance_penalty: parsedConfig.tier_distance_penalty,
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index fca8420966fe..23ae905d9db0 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -111,6 +111,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
};
expect(getMissingModels(config, availability)).toEqual([]);
expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([group]);
@@ -153,6 +154,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
};
expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual(["a-group"]);
});
@@ -166,6 +168,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
};
expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-opus-5"]);
});
@@ -198,6 +201,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
});
it("resolves a preset model to a group expanded from a wildcard deployment", () => {
@@ -440,6 +444,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
match_threshold: 0,
escalation_keywords: [],
};
@@ -454,6 +459,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
session_affinity: false,
+ deployment_affinity: true,
},
groupsOnly(["gpt-5-nano"]),
);
@@ -469,6 +475,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
};
const labeled = buildPresetPrefill(
{ ...base, tier_labels: { SIMPLE: "Cheap", REASONING: "Deep" } },
@@ -483,6 +490,7 @@ describe("autorouter_presets", () => {
tiers: { SIMPLE: ["claude-sonnet-4-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
session_affinity: false,
+ deployment_affinity: true,
};
const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
expect(prefill.complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-sonnet-4.5"]);
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index ae3f30c90f50..5caff6fe714f 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -8,6 +8,7 @@ import {
ClassifierType,
ClassifierLLMConfig,
DEFAULT_SESSION_AFFINITY,
+ DEFAULT_DEPLOYMENT_AFFINITY,
} from "@/components/add_model/ComplexityRouterConfig";
import { KeywordTierRule } from "@/components/add_model/KeywordTierRules";
import { hydrateKeywordTierRules } from "@/components/add_model/complexity_router_keywords";
@@ -260,6 +261,7 @@ export const buildPresetPrefill = (
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
+ deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
adaptive: config.adaptive,
adaptive_weights: config.adaptive_weights,
tier_distance_penalty: config.tier_distance_penalty,