Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions litellm/router_strategy/complexity_router/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,13 +426,13 @@ class ComplexityRouterConfig(BaseModel):

# Session affinity: pin the first turn's routed model for the rest of the session
session_affinity: bool = Field(
default=True,
default=False,
description=(
"When True and a session_id is resolvable on the request, pin the model chosen on the "
"session's first turn and reuse it for every later turn, skipping re-classification. "
"On by default so multi-turn sessions stay on one model, preserving provider prompt "
"caches and avoiding cross-model conversation-history errors. Set False to reclassify "
"every turn."
"Off by default so every turn is classified on its own merits and routed to the cheapest "
"adequate tier. Set True to keep a multi-turn session on one model, which preserves "
"provider prompt caches and avoids cross-model conversation-history errors."
),
)
session_affinity_ttl_seconds: int = Field(
Expand Down
26 changes: 10 additions & 16 deletions tests/test_litellm/router_strategy/test_complexity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2903,7 +2903,7 @@ async def test_complexity_scorer_logs_its_cause(self, mock_router_instance, basi


class TestSessionAffinity:
"""Test the session_affinity sticky-routing behavior (on by default)."""
"""Test the session_affinity sticky-routing behavior (off by default)."""

REASONING_MESSAGE = [
{
Expand All @@ -2917,18 +2917,14 @@ class TestSessionAffinity:
def session_affinity_config(self, basic_config) -> Dict:
return {**basic_config, "session_affinity": True}

@pytest.fixture
def session_affinity_disabled_config(self, basic_config) -> Dict:
return {**basic_config, "session_affinity": False}

@staticmethod
def _request_kwargs(session_id: str) -> Dict:
return {"metadata": {"session_id": session_id}}

@pytest.mark.asyncio
async def test_enabled_by_default_pins_model(self, mock_router_instance, basic_config):
"""Regression: session_affinity defaults to True, so a shared session_id pins the
first turn's model and later turns reuse it instead of reclassifying."""
async def test_disabled_by_default_reclassifies_every_turn(self, mock_router_instance, basic_config):
"""Regression: session_affinity defaults to False, so a shared session_id must NOT
pin the first turn's model; every turn is classified on its own merits."""
assert "session_affinity" not in basic_config
mock_router_instance.cache = DualCache()
router = ComplexityRouter(
Expand All @@ -2944,19 +2940,17 @@ async def test_enabled_by_default_pins_model(self, mock_router_instance, basic_c
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
)
assert first.model == "o1-preview"
assert second.model == "o1-preview"
assert second.model == "gpt-4o-mini"

@pytest.mark.asyncio
async def test_can_be_disabled_reclassifies_every_turn(
self, mock_router_instance, session_affinity_disabled_config
):
"""Regression: session_affinity=False must still reclassify every turn even when a
shared session_id is present, so the opt-out keeps working."""
async def test_can_be_enabled_to_pin_every_later_turn(self, mock_router_instance, session_affinity_config):
"""Regression: session_affinity=True is the opt-in, so a shared session_id reuses the
first turn's model instead of reclassifying."""
mock_router_instance.cache = DualCache()
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=session_affinity_disabled_config,
complexity_router_config=session_affinity_config,
)
request_kwargs = self._request_kwargs("session-1")
first = await router.async_pre_routing_hook(
Expand All @@ -2966,7 +2960,7 @@ async def test_can_be_disabled_reclassifies_every_turn(
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
)
assert first.model == "o1-preview"
assert second.model == "gpt-4o-mini"
assert second.model == "o1-preview"

@pytest.mark.asyncio
async def test_pins_model_after_first_turn(self, mock_router_instance, session_affinity_config):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
export const DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS = 200;
export const DEFAULT_SESSION_AFFINITY = false;

export interface ComplexityTiers {
SIMPLE: string[];
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface ComplexityRouterConfigValue {
classifier_context_window_size?: number;
classifier_context_per_turn_chars?: number;
classifier_context_include_assistant_turns?: boolean;
session_affinity?: boolean;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
Expand Down Expand Up @@ -224,6 +226,32 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
),
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
},
{
key: "session-affinity",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Session Affinity
</Text>
),
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
onChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
aria-label="Pin a session to its first model"
/>
<Text strong>Pin a session to its first model</Text>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
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&apos;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&apos;s tier.
</Text>
</>
),
},
{
key: "response",
label: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,40 @@ describe("AddAutoRouterTab", () => {
expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});

it("defaults a new router to session affinity off, matching the backend field default", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);

renderWithProviders(<Harness />);

await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
await user.click(screen.getByText("Advanced: Session 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 }));

await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
session_affinity: false,
});
});

it("carries session affinity turned on 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), "affinity-router");
await user.click(screen.getByText("Advanced: Session 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 }));

await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
session_affinity: true,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
} from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
Expand Down Expand Up @@ -70,7 +71,7 @@
useEffect(() => {
const fetchModelAccessGroups = async () => {
const response = await modelAvailableCall(accessToken, "", "", false, null, true, true);
setModelAccessGroups(response["data"].map((model: any) => model["id"]));

Check warning on line 74 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
};
fetchModelAccessGroups();
}, [accessToken]);
Expand Down Expand Up @@ -102,6 +103,7 @@
classifier_context_window_size: classifierContextWindowSize,
classifier_context_per_turn_chars: classifierContextPerTurnChars,
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
session_affinity: sessionAffinity = DEFAULT_SESSION_AFFINITY,
adaptive = false,
adaptive_weights: adaptiveWeights = DEFAULT_ADAPTIVE_WEIGHTS,
tier_distance_penalty: tierDistancePenalty = DEFAULT_TIER_DISTANCE_PENALTY,
Expand Down Expand Up @@ -129,9 +131,9 @@
return;
}

const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];

Check warning on line 134 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable

form.setFieldsValue({

Check warning on line 136 in ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 4 properties passed inline as an argument; assign it to a named variable first
custom_llm_provider: "auto_router",
model: name,
api_key: "not_required_for_auto_router",
Expand All @@ -148,6 +150,7 @@
classifierContextWindowSize,
classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
sessionAffinity,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
classifierContextWindowSize: undefined,
classifierContextPerTurnChars: undefined,
classifierContextIncludeAssistantTurns: undefined,
sessionAffinity: false,
customTechnicalKeywords: [],
keywordTierRules: [],
semanticMatchingEnabled: false,
Expand All @@ -35,7 +36,12 @@
describe("buildComplexityRouterConfig", () => {
it("emits tiers, classifier_type, and escalation_keywords when nothing else is configured", () => {
const config = buildComplexityRouterConfig(baseParams);
expect(config).toEqual({ tiers, classifier_type: "heuristic", escalation_keywords: ["LITELLM ESCALATE"] });
expect(config).toEqual({

Check warning on line 39 in ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 4 properties passed inline as an argument; assign it to a named variable first
tiers,
classifier_type: "heuristic",
session_affinity: false,
escalation_keywords: ["LITELLM ESCALATE"],
});
});

it("trims escalation keywords and drops blank entries", () => {
Expand Down Expand Up @@ -201,7 +207,7 @@
});

it("omits adaptive fields when adaptive is disabled even if weights linger in state", () => {
const config = buildComplexityRouterConfig({

Check warning on line 210 in ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 5 properties passed inline as an argument; assign it to a named variable first
...baseParams,
adaptive: false,
adaptiveWeights: { quality: 0.9, cost: 0.1 },
Expand All @@ -219,13 +225,23 @@
expect(config.return_raw_model_name).toBeUndefined();
});

it("writes session_affinity=true so turning the toggle on overrides the backend's off-by-default", () => {
const config = buildComplexityRouterConfig({ ...baseParams, sessionAffinity: true });
expect(config.session_affinity).toBe(true);
});

it("writes session_affinity explicitly when off, so the stored config never relies on the backend default", () => {
const config = buildComplexityRouterConfig({ ...baseParams, sessionAffinity: false });
expect(config.session_affinity).toBe(false);
});

it("includes return_raw_model_name when enabled", () => {
const config = buildComplexityRouterConfig({ ...baseParams, returnRawModelName: true });
expect(config.return_raw_model_name).toBe(true);
});

it("includes tier_distance_penalty when adaptive is enabled with eligible='all'", () => {
const config = buildComplexityRouterConfig({

Check warning on line 244 in ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 5 properties passed inline as an argument; assign it to a named variable first
...baseParams,
adaptive: true,
adaptiveWeights: { quality: 0.6, cost: 0.4 },
Expand All @@ -239,7 +255,7 @@
});

it("omits tier_distance_penalty when eligible='classified_tier', since the penalty doesn't apply there", () => {
const config = buildComplexityRouterConfig({

Check warning on line 258 in ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 5 properties passed inline as an argument; assign it to a named variable first
...baseParams,
adaptive: true,
adaptiveWeights: { quality: 0.6, cost: 0.4 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface BuildComplexityRouterConfigParams {
classifierContextWindowSize: number | undefined;
classifierContextPerTurnChars: number | undefined;
classifierContextIncludeAssistantTurns: boolean | undefined;
sessionAffinity: boolean;
customTechnicalKeywords: string[];
keywordTierRules: KeywordTierRule[];
semanticMatchingEnabled: boolean;
Expand All @@ -35,6 +36,7 @@ export interface ComplexityRouterConfigPayload {
classifier_context_window_size?: number;
classifier_context_per_turn_chars?: number;
classifier_context_include_assistant_turns?: boolean;
session_affinity: boolean;
custom_technical_keywords?: string[];
keyword_tier_rules?: { keywords: string[]; tier: KeywordTierRule["tier"] }[];
semantic_keyword_matching?: boolean;
Expand Down Expand Up @@ -78,6 +80,7 @@ export const buildComplexityRouterConfig = ({
classifierContextWindowSize,
classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
sessionAffinity,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
Expand Down Expand Up @@ -110,6 +113,7 @@ export const buildComplexityRouterConfig = ({
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
session_affinity: sessionAffinity,
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
escalation_keywords: cleanedEscalationKeywords,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,28 @@ describe("buildUpdatedComplexityRouterConfig assistant turns", () => {
expect(result.classifier_context_include_assistant_turns).toBeUndefined();
});
});

describe("buildUpdatedComplexityRouterConfig session affinity", () => {
it("writes session_affinity=false when the toggle is off", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, session_affinity: false });
expect(result.session_affinity).toBe(false);
});

it("writes session_affinity=true when the toggle is on", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, session_affinity: true });
expect(result.session_affinity).toBe(true);
});

it("re-asserts the backend's off-by-default when the form value is absent, rather than dropping the key", () => {
const result = buildUpdatedComplexityRouterConfig({ ...STORED, session_affinity: true }, FORM_VALUE);
expect(result.session_affinity).toBe(false);
});

it("stops a stored session_affinity=true from surviving a save that turned the toggle back off", () => {
const result = buildUpdatedComplexityRouterConfig(
{ ...STORED, session_affinity: true },
{ ...FORM_VALUE, session_affinity: false },
);
expect(result.session_affinity).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const expectedClassifiedTierConfig = {
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.65,
session_affinity: false,
adaptive: true,
adaptive_weights: { quality: 0.4, cost: 0.6 },
adaptive_eligible: "classified_tier",
Expand All @@ -66,6 +67,7 @@ const expectedAdaptiveDisabledConfig = {
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.65,
session_affinity: false,
};

describe("buildUpdatedComplexityRouterConfig", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,76 @@ describe("EditAutoRouterModal assistant turns", () => {
expect(savedConfig().classifier_context_include_assistant_turns).toBe(false);
});
});

describe("EditAutoRouterModal session affinity", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});

const renderWithStoredConfig = (complexity_router_config: Record<string, unknown>) =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{ ...MODEL_DATA, litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config } }}
accessToken="token"
userRole="Admin"
/>,
);

// A stored config with no session_affinity key now runs with affinity OFF, because the backend
// field defaults to False. The toggle has to render what the router actually does, and an
// untouched save must not flip it.
it("shows a stored config with no session_affinity key as off", async () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);

await user.click(await screen.findByText("Advanced: Session 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 }));

await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(false);
});

it("shows a stored session_affinity=true as on and preserves it through an untouched save", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });

await user.click(await screen.findByText("Advanced: Session 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 }));

await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(true);
});

it("persists turning session affinity on", async () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);

await user.click(await screen.findByText("Advanced: Session 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 }));

await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(true);
});

it("persists turning session affinity back off", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });

await user.click(await screen.findByText("Advanced: Session 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 }));

await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(false);
});
});
Loading
Loading