Skip to content

Commit 31a6756

Browse files
authored
feat(complexity_router): bound the classifier context block, not each turn in it (#38145)
The LLM classifier capped every prior turn at 200 characters independently, so a 785 character turn was cut even when the whole block it belonged to was 353 characters. A character budget now bounds the block: turns are taken newest first and quoted whole while they fit, older turns are dropped whole once it runs out, and only the turn straddling the boundary is cut. The per-turn cap stays as an optional clamp for operators who set it deliberately, defaulting to unset.
1 parent a9c7b84 commit 31a6756

14 files changed

Lines changed: 356 additions & 90 deletions

litellm/router_strategy/complexity_router/complexity_router.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import random
2020
import re
2121
from collections.abc import Iterator, Mapping, Sequence
22-
from itertools import accumulate, islice
22+
from itertools import accumulate, islice, takewhile
2323
from types import MappingProxyType
2424
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast
2525

@@ -275,6 +275,7 @@ def _effective_turn_off_message_logging(request_kwargs: Mapping[str, Any] | None
275275

276276
_TRUNCATION_MARKER: Final = "..."
277277
_TRUNCATION_HEAD_FRACTION: Final = 0.3
278+
_MIN_QUOTED_TURN_CHARS: Final = 120
278279

279280
_CJK_CHARACTER: Final = re.compile("[぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-ン\U00020000-\U0003ffff]")
280281

@@ -593,11 +594,40 @@ def _iter_context_turns_newest_first(
593594
)
594595

595596

597+
def _turns_within_budget(
598+
turns: Sequence[tuple[str, str]],
599+
budget_chars: int,
600+
) -> tuple[tuple[str, str], ...]:
601+
"""The newest-first turns that fit budget_chars, quoted whole wherever they fit.
602+
603+
Bounding the block rather than every turn in it is what lets an ordinary conversation reach the
604+
classifier intact: a per-turn cap cuts a 785 character turn even when the whole block would have
605+
been 353 characters, which is three orders of magnitude below anything the classifier call is
606+
near. Once the budget does run out the older turns are dropped entire rather than shortened, so
607+
at most one turn is ever cut and the rest read as themselves. A remainder too small to carry a
608+
sentence buys less signal than the ellipses it would arrive wrapped in, so that turn is dropped.
609+
610+
The boundary turn is cut to leave room for the marker rather than to the remainder itself, so the
611+
quoted block never exceeds budget_chars; the marker is part of what the budget buys, not an extra
612+
charged on top of it.
613+
"""
614+
spent: Final = accumulate(len(text) for _, text in turns)
615+
fitting: Final = tuple(takewhile(lambda pair: pair[1] <= budget_chars, zip(turns, spent)))
616+
remaining: Final = budget_chars - (fitting[-1][1] if fitting else 0)
617+
whole: Final = tuple(turn for turn, _ in fitting)
618+
cut_to: Final = remaining - len(_TRUNCATION_MARKER)
619+
if len(whole) == len(turns) or cut_to < _MIN_QUOTED_TURN_CHARS:
620+
return whole
621+
boundary_role, boundary_text = turns[len(whole)]
622+
return (*whole, (boundary_role, _truncate(boundary_text, cut_to)))
623+
624+
596625
def _extract_prior_turns(
597626
messages: Sequence[Mapping[str, object]],
598627
current_ask: str | None,
599628
window_size: int,
600-
per_turn_chars: int,
629+
budget_chars: int,
630+
per_turn_chars: int | None,
601631
include_assistant: bool,
602632
marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS,
603633
) -> tuple[tuple[str, str], ...]:
@@ -612,19 +642,29 @@ def _extract_prior_turns(
612642
window_size counts turns of every eligible role, so with assistant turns included it is the last N
613643
of the conversation rather than the last N asks. A turn carrying only tool calls or thinking
614644
blocks flattens to empty text and is skipped, so it never spends a slot.
645+
646+
Three bounds apply and the tightest wins: window_size caps how many turns, budget_chars caps the
647+
block they form, and per_turn_chars optionally caps any single one of them before the block is
648+
measured. They are separate because they answer separate questions, and only the block bound
649+
tracks what the classifier call actually costs.
615650
"""
616651
if window_size <= 0 or not messages:
617652
return ()
618653

619-
prior: Final = islice(
620-
(
621-
turn
622-
for turn in _iter_context_turns_newest_first(messages, include_assistant, marker_pairs)
623-
if turn[1] != current_ask
624-
),
625-
window_size,
654+
prior: Final = tuple(
655+
islice(
656+
(
657+
turn
658+
for turn in _iter_context_turns_newest_first(messages, include_assistant, marker_pairs)
659+
if turn[1] != current_ask
660+
),
661+
window_size,
662+
)
663+
)
664+
clamped: Final = (
665+
prior if per_turn_chars is None else tuple((role, _truncate(text, per_turn_chars)) for role, text in prior)
626666
)
627-
return tuple((role, _truncate(text, per_turn_chars)) for role, text in reversed(tuple(prior)))
667+
return tuple(reversed(_turns_within_budget(clamped, budget_chars)))
628668

629669

630670
def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bool:
@@ -1363,6 +1403,7 @@ async def _classify_with_llm(
13631403
messages,
13641404
current_ask=prompt,
13651405
window_size=self.config.classifier_context_window_size,
1406+
budget_chars=self.config.classifier_context_budget_chars,
13661407
per_turn_chars=self.config.classifier_context_per_turn_chars,
13671408
include_assistant=include_assistant,
13681409
marker_pairs=self._reminder_markers,

litellm/router_strategy/complexity_router/config.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class ClassificationRubric(str, Enum):
4949
DEFAULT_TIER_DISTANCE_PENALTY: Final[float] = 0.5
5050

5151
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE: Final[int] = 3
52-
DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS: Final[int] = 200
52+
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS: Final[int] = 8000
5353

5454

5555
class KeywordTierRule(BaseModel):
@@ -645,12 +645,30 @@ class ComplexityRouterConfig(BaseModel):
645645
"classifier_type is 'llm'."
646646
),
647647
)
648-
classifier_context_per_turn_chars: int = Field(
649-
default=DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS,
648+
classifier_context_budget_chars: int = Field(
649+
default=DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
650+
ge=0,
651+
description=(
652+
"Maximum characters of prior-turn text quoted to the LLM classifier, across the whole "
653+
"context window, per classification call. Turns are taken newest first and quoted whole "
654+
"while they fit, so a conversation small enough to quote entirely is never cut; once the "
655+
"budget runs out the older turns are dropped whole and only the turn straddling the "
656+
"boundary is truncated, into whatever space is left. The current ask and the caller's "
657+
"system prompt sit outside this budget and are always sent in full, as does the numbering "
658+
"each quoted turn carries. A budget under 120 leaves no room to quote a turn and "
659+
"suppresses the block; set classifier_context_window_size to 0 to turn context off "
660+
"deliberately. Only applies when classifier_type is 'llm'."
661+
),
662+
)
663+
classifier_context_per_turn_chars: int | None = Field(
664+
default=None,
650665
gt=0,
651666
description=(
652-
"Maximum character length for each prior turn's text in the classifier context window. "
653-
"Turns exceeding this are truncated. Only applies when classifier_type is 'llm'."
667+
"Optional cap on each individual prior turn's text, applied before "
668+
"classifier_context_budget_chars bounds the block. Unset by default, so one long turn may "
669+
"spend the whole budget, which is usually what a follow-up needs; set it when no single "
670+
"turn should dominate the context the classifier sees. A capped turn keeps its opening "
671+
"and its ending with the middle elided. Only applies when classifier_type is 'llm'."
654672
),
655673
)
656674
classifier_context_include_assistant_turns: bool = Field(
@@ -662,9 +680,9 @@ class ComplexityRouterConfig(BaseModel):
662680
"word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the "
663681
"conversation across both roles rather than the last N user turns, and assistant text is "
664682
"sent to the classifier model, which may be a different deployment or provider than the "
665-
"routed completion model. Assistant replies share classifier_context_per_turn_chars with "
666-
"user turns, so raise it if replies are truncated before the part that carries the "
667-
"difficulty. Off by default because enabling it shifts tier decisions, and therefore "
683+
"routed completion model. Assistant replies spend classifier_context_budget_chars "
684+
"alongside user turns, so raise it if the oldest turns stop being quoted once replies "
685+
"join the window. Off by default because enabling it shifts tier decisions, and therefore "
668686
"spend, for an already-deployed router. Only applies when classifier_type is 'llm'."
669687
),
670688
)

tests/test_litellm/router_strategy/test_complexity_router.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6052,8 +6052,9 @@ def test_clipped_prior_turn_still_carries_the_ask_it_closes_on(self):
60526052
[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
60536053
"go ahead",
60546054
3,
6055-
200,
6056-
False,
6055+
budget_chars=10_000,
6056+
per_turn_chars=200,
6057+
include_assistant=False,
60576058
)
60586059

60596060
assert "multi-region gateway" in quoted[0][1]
@@ -6216,7 +6217,167 @@ def test_prior_turn_window(self, messages, current_ask, window, per_turn_chars,
62166217
"""
62176218
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
62186219

6219-
assert _extract_prior_turns(messages, current_ask, window, per_turn_chars, include_assistant) == expected
6220+
assert (
6221+
_extract_prior_turns(
6222+
messages,
6223+
current_ask,
6224+
window,
6225+
budget_chars=10_000,
6226+
per_turn_chars=per_turn_chars,
6227+
include_assistant=include_assistant,
6228+
)
6229+
== expected
6230+
)
6231+
6232+
@pytest.mark.parametrize(
6233+
"turn_lengths,budget_chars,expected_lengths",
6234+
[
6235+
pytest.param((50, 50, 50), 10_000, (50, 50, 50), id="a-block-that-fits-is-quoted-whole"),
6236+
pytest.param((100, 100, 100), 250, (100, 100), id="oldest-turn-is-dropped-whole"),
6237+
pytest.param((500, 100), 400, (300, 100), id="only-the-boundary-turn-is-cut"),
6238+
pytest.param((900,), 300, (300,), id="a-turn-larger-than-the-budget-is-still-quoted"),
6239+
pytest.param((500, 100), 180, (100,), id="a-remainder-too-small-to-carry-a-sentence-is-dropped"),
6240+
pytest.param((50,), 0, (), id="a-zero-budget-quotes-nothing"),
6241+
],
6242+
)
6243+
def test_budget_bounds_the_block_not_each_turn(self, turn_lengths, budget_chars, expected_lengths):
6244+
"""Turns are taken newest first and quoted whole while they fit.
6245+
6246+
The defect this replaces capped every turn independently, so a 785 character turn was cut even
6247+
though the whole block it belonged to was 353 characters. Bounding the block instead means an
6248+
ordinary conversation arrives intact, and when the budget really does run out the older turns
6249+
are dropped entire rather than each arriving mangled. At most one turn is ever cut, and a
6250+
remainder too small to carry a sentence is dropped rather than quoted as two ellipses around a
6251+
fragment. A single turn bigger than the whole budget is still quoted, cut to the budget, since
6252+
dropping it would leave the classifier with no context at all.
6253+
"""
6254+
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
6255+
6256+
messages = [{"role": "user", "content": f"{i}" * length} for i, length in enumerate(turn_lengths)]
6257+
6258+
quoted = _extract_prior_turns(
6259+
[*messages, {"role": "user", "content": "go ahead"}],
6260+
"go ahead",
6261+
len(turn_lengths),
6262+
budget_chars=budget_chars,
6263+
per_turn_chars=None,
6264+
include_assistant=False,
6265+
)
6266+
6267+
assert tuple(len(text) for _, text in quoted) == expected_lengths
6268+
6269+
@pytest.mark.parametrize("budget_chars", [130, 200, 351, 400, 999, 8000])
6270+
@pytest.mark.parametrize("turn_lengths", [(900,), (500, 100), (100, 100, 100), (50, 50, 50)])
6271+
def test_the_quoted_block_never_exceeds_the_budget(self, turn_lengths, budget_chars):
6272+
"""The budget is a ceiling on what is quoted, marker included.
6273+
6274+
Cutting the boundary turn to the remainder and then appending the marker put the block three
6275+
characters over the number an operator configured, which is the kind of drift that makes a
6276+
documented ceiling untrue. Asserted across shapes rather than at the one boundary that happened
6277+
to be wrong, so any future off-by-marker anywhere in the fill is caught here.
6278+
"""
6279+
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
6280+
6281+
messages = [{"role": "user", "content": f"{i}" * length} for i, length in enumerate(turn_lengths)]
6282+
6283+
quoted = _extract_prior_turns(
6284+
[*messages, {"role": "user", "content": "go ahead"}],
6285+
"go ahead",
6286+
len(turn_lengths),
6287+
budget_chars=budget_chars,
6288+
per_turn_chars=None,
6289+
include_assistant=False,
6290+
)
6291+
6292+
assert sum(len(text) for _, text in quoted) <= budget_chars
6293+
6294+
def test_per_turn_cap_still_clamps_when_an_operator_sets_it(self):
6295+
"""An operator who set the per-turn cap keeps exactly what they configured.
6296+
6297+
The cap stopped being the default, so it has to keep working for the deployments that named it
6298+
deliberately; it applies before the block budget rather than instead of it.
6299+
"""
6300+
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
6301+
6302+
quoted = _extract_prior_turns(
6303+
[{"role": "user", "content": "z" * 900}, {"role": "user", "content": "go ahead"}],
6304+
"go ahead",
6305+
3,
6306+
budget_chars=10_000,
6307+
per_turn_chars=200,
6308+
include_assistant=False,
6309+
)
6310+
6311+
assert len(quoted[0][1]) == 203
6312+
6313+
@pytest.mark.asyncio
6314+
async def test_a_long_turn_reaches_the_classifier_whole_by_default(
6315+
self, mock_router_instance, llm_classifier_config
6316+
):
6317+
"""The shipped defaults quote an ordinary long turn without cutting it anywhere.
6318+
6319+
This is the whole point of the change, asserted where a deployment actually meets it: no knob
6320+
set, one turn well past the retired 200 character cap, and no truncation marker in the payload.
6321+
"""
6322+
from litellm.router_strategy.complexity_router.complexity_router import _TRUNCATION_MARKER
6323+
6324+
router = ComplexityRouter(
6325+
model_name="test-complexity-router",
6326+
litellm_router_instance=mock_router_instance,
6327+
complexity_router_config=llm_classifier_config,
6328+
)
6329+
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
6330+
turn = "The incident ran from 02:10 to 02:40 and only streaming was affected. " * 10 + "Now rewrite it"
6331+
6332+
await router.aclassify(
6333+
"go ahead",
6334+
messages=[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
6335+
)
6336+
6337+
user_payload = mock_router_instance.acompletion.call_args.kwargs["messages"][1]["content"]
6338+
assert turn in user_payload
6339+
assert _TRUNCATION_MARKER not in user_payload
6340+
6341+
@pytest.mark.asyncio
6342+
async def test_a_turn_dropped_for_budget_still_counts_as_prior_conversation(
6343+
self, mock_router_instance, llm_classifier_config
6344+
):
6345+
"""Dropping turns to fit the budget must not make a long conversation look single-turn.
6346+
6347+
The depth line gates on whether prior conversation exists, not on whether any of it was worth
6348+
quoting, exactly so a continuation is never reported as a context-free first request. A budget
6349+
tight enough to drop every turn is the newest way to reach that mismatch.
6350+
"""
6351+
router = ComplexityRouter(
6352+
model_name="test-complexity-router",
6353+
litellm_router_instance=mock_router_instance,
6354+
complexity_router_config={**llm_classifier_config, "classifier_context_budget_chars": 1},
6355+
)
6356+
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
6357+
6358+
await router.aclassify(
6359+
"go ahead",
6360+
messages=[
6361+
{"role": "user", "content": "a long earlier request that cannot fit a one character budget"},
6362+
{"role": "user", "content": "go ahead"},
6363+
],
6364+
)
6365+
6366+
user_payload = mock_router_instance.acompletion.call_args.kwargs["messages"][1]["content"]
6367+
assert "Recent conversation" not in user_payload
6368+
assert "Conversation so far" in user_payload
6369+
6370+
def test_context_defaults_bound_the_block_and_leave_turns_uncapped(self):
6371+
"""The shipped defaults: a block budget, and no per-turn cap unless one is named."""
6372+
from litellm.router_strategy.complexity_router.config import (
6373+
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
6374+
ComplexityRouterConfig,
6375+
)
6376+
6377+
config = ComplexityRouterConfig()
6378+
6379+
assert config.classifier_context_budget_chars == DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
6380+
assert config.classifier_context_per_turn_chars is None
62206381

62216382
def test_prior_turn_context_strips_every_configured_pair(self):
62226383
"""The classifier's context window is stripped with the same pairs as the ask.
@@ -6235,7 +6396,7 @@ def test_prior_turn_context_strips_every_configured_pair(self):
62356396
{"role": "user", "content": "current ask"},
62366397
]
62376398

6238-
assert _extract_prior_turns(messages, "current ask", 5, 200, False, pairs) == (
6399+
assert _extract_prior_turns(messages, "current ask", 5, 10_000, 200, False, pairs) == (
62396400
("user", "what about b-trees?"),
62406401
("user", "and heaps?"),
62416402
)

0 commit comments

Comments
 (0)